# # This Dockerfile is used for self-hosted production builds. # # PostHog has sunset support for self-hosted K8s deployments. # See: https://posthog.com/blog/sunsetting-helm-support-posthog # # Note: PostHog Cloud uses this same image (re-tagged to ECR posthog-cloud); there is no separate Dockerfile.cloud. # # The stages are used to: # # - node-base: shared Node.js base with pnpm already provisioned # - frontend-build: build the frontend (static assets) # - sourcemap-upload: upload sourcemaps to PostHog (isolated, no artifacts) # - node-scripts-build: build plugin transpiler and other Node.js build artifacts # - posthog-build: fetch PostHog (Django app) dependencies & build Django collectstatic # - fetch-geoip-db: fetch the GeoIP database # # Node.js services are built separately using Dockerfile.node. # # In the last stage, we import the artifacts from the previous # stages, add some runtime dependencies and build the final image. # # # --------------------------------------------------------- # # Digest-pinned because the runtime stage copies its `node` binary out of this stage. FROM node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 AS node-base WORKDIR /code SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] # corepack fetches the pinned pnpm with a bare fetch() — no timeout, no retries — so a stalled # registry connection blocks until the job timeout kills the build. Seeding it here keeps that fetch # off every source change: only a root package.json edit re-runs this layer. Then take corepack off # the network, so a pin this layer does not cover fails in milliseconds naming the URL it wanted. COPY package.json ./ RUN corepack enable && corepack install ENV COREPACK_ENABLE_NETWORK=0 # # --------------------------------------------------------- # FROM node-base AS frontend-build COPY turbo.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY frontend/package.json frontend/ COPY frontend/bin/ frontend/bin/ COPY bin/ bin/ COPY patches/ patches/ COPY common/hogvm/typescript/ common/hogvm/typescript/ COPY common/esbuilder/ common/esbuilder/ COPY common/replay-shared/ common/replay-shared/ COPY common/tailwind/ common/tailwind/ COPY packages/quill/ packages/quill/ COPY packages/llm-normalizer/ packages/llm-normalizer/ COPY packages/agent/packages/agent-contracts/src/ packages/agent/packages/agent-contracts/src/ COPY products/ products/ COPY docs/onboarding/ docs/onboarding/ RUN --mount=type=cache,id=pnpm,target=/tmp/pnpm-store-v24 \ CI=1 pnpm --filter=@posthog/frontend... install --frozen-lockfile --store-dir /tmp/pnpm-store-v24 COPY frontend/ frontend/ RUN bin/turbo --filter=@posthog/frontend build # # --------------------------------------------------------- # # Isolated stage for sourcemap upload - keeps secrets and external network calls # out of the main build cache. This stage produces no artifacts for the final image. # FROM node:24.22.1-bookworm-slim AS sourcemap-upload WORKDIR /code SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] ARG COMMIT_HASH COPY --from=frontend-build /code/frontend/dist /code/frontend/dist # Upload sourcemaps to error tracking, recording whether it GENUINELY succeeded so the later strip # only deletes the .map files when an uploaded copy exists. The build never fails on a sourcemap # problem (missing secret, CLI download / network / auth failure): in that case the status is # "retained" and the .map files are kept in the image. Uses explicit && chaining rather than `set -e`, # which bash ignores inside a `||`-guarded subshell — any failing link drops us into the retained branch. # # Two passes. The stable-name copies (`*-S<10 hex>.js`, see frontend/bin/stableChunkNames.mjs) keep # one URL for as long as their code is unchanged, so they must not carry the per-release id that the # first pass injects. The second pass runs with no release flags and without GITHUB_ACTIONS, so the CLI # resolves no release and injects only the chunk id, which it derives from the file's own content. # # The CLI installer is pinned to an immutable release tag and checksum-verified before execution: # the processed frontend/dist ships in the final image, so the CLI must not be mutable remote code. # To upgrade, change POSTHOG_CLI_VERSION and recompute the hash: # curl -LsSf "https://github.com/PostHog/posthog/releases/download/posthog-cli%2Fv/posthog-cli-installer.sh" | sha256sum ARG POSTHOG_CLI_VERSION=0.18.2 ARG POSTHOG_CLI_INSTALLER_SHA256=1ed5ff785ca33f38458efb1677ffd35ed99d935ac59d5e28bfd0d07a4f697f7a # The CLI stamps the release it creates with git metadata (branch, remote, repo name) read from the # GitHub Actions environment. Only frontend/dist is copied into this stage, so there is no .git # directory to fall back on: without these the release is created with no link back to the code it # was built from, and the CLI skips the metadata silently because --release-name/--release-version # already let it create the release. The CLI treats empty values as absent, so local builds that # pass none of these behave as before. In the CD workflow the release usually exists already, created # with the same metadata before the build, and this stage only looks it up by name and version. ARG GITHUB_ACTIONS ARG GITHUB_SHA ARG GITHUB_REF_NAME ARG GITHUB_REPOSITORY ARG GITHUB_SERVER_URL ENV GITHUB_ACTIONS=$GITHUB_ACTIONS \ GITHUB_SHA=$GITHUB_SHA \ GITHUB_REF_NAME=$GITHUB_REF_NAME \ GITHUB_REPOSITORY=$GITHUB_REPOSITORY \ GITHUB_SERVER_URL=$GITHUB_SERVER_URL RUN --mount=type=secret,id=posthog_upload_sourcemaps_cli_api_key \ if ( \ [ -f /run/secrets/posthog_upload_sourcemaps_cli_api_key ] && \ apt-get update && \ apt-get install -y --no-install-recommends ca-certificates curl && \ curl --proto '=https' --tlsv1.2 -LsSf -o /tmp/posthog-cli-installer.sh \ "https://github.com/PostHog/posthog/releases/download/posthog-cli%2Fv${POSTHOG_CLI_VERSION}/posthog-cli-installer.sh" && \ echo "${POSTHOG_CLI_INSTALLER_SHA256} /tmp/posthog-cli-installer.sh" | sha256sum -c - && \ sh /tmp/posthog-cli-installer.sh && \ export PATH="/root/.posthog:$PATH" && \ export POSTHOG_CLI_TOKEN="$(cat /run/secrets/posthog_upload_sourcemaps_cli_api_key)" && \ export POSTHOG_CLI_ENV_ID=2 && \ STABLE_CHUNKS='**/*-S[0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F][0-9A-F].js' && \ posthog-cli sourcemap process \ --directory /code/frontend/dist \ --exclude "$STABLE_CHUNKS" \ --public-path-prefix /static \ --release-mode event \ --release-name posthog \ --release-version "${COMMIT_HASH:-unknown}" && \ env -u GITHUB_ACTIONS posthog-cli sourcemap process \ --directory /code/frontend/dist \ --include "$STABLE_CHUNKS" \ --public-path-prefix /static \ --release-mode event \ ); then \ echo uploaded > /tmp/.sourcemaps-status; \ else \ echo "WARNING: sourcemaps not uploaded (no secret or upload failed); .map files will be retained in the image" >&2; \ echo retained > /tmp/.sourcemaps-status; \ fi && \ touch /tmp/.sourcemaps-processed # # --------------------------------------------------------- # # Build plugin transpiler and other Node.js build artifacts. # FROM node-base AS node-scripts-build # Build plugin transpiler for site destinations/apps COPY turbo.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json ./ COPY bin/turbo bin/turbo COPY patches/ patches/ COPY common/esbuilder/ common/esbuilder/ COPY common/plugin_transpiler/ common/plugin_transpiler/ RUN --mount=type=cache,id=pnpm,target=/tmp/pnpm-store-v24 \ NODE_OPTIONS="--max-old-space-size=4096" CI=1 pnpm --filter=@posthog/plugin-transpiler... install --frozen-lockfile --store-dir /tmp/pnpm-store-v24 && \ NODE_OPTIONS="--max-old-space-size=4096" bin/turbo --filter=@posthog/plugin-transpiler build COPY products/canvas/packages/canvas_builder/ products/canvas/packages/canvas_builder/ RUN --mount=type=cache,id=npm,target=/root/.npm \ npm ci --ignore-scripts --omit=dev --no-audit --no-fund --prefix products/canvas/packages/canvas_builder # The transpiler bundle externalizes @babel/standalone (its only external runtime require — a # self-contained 24MB package with no deps). Materialize it as real files inside the transpiler's # own node_modules, replacing the pnpm symlink that pointed into the root node_modules. The final # image then carries just this package instead of the entire ~469MB root /code/node_modules. RUN cd /code/common/plugin_transpiler && \ BABEL_REAL=$(node -e "process.stdout.write(require('path').dirname(require.resolve('@babel/standalone/package.json')))") && \ rm -rf node_modules/@babel/standalone && \ mkdir -p node_modules/@babel && \ cp -rL "$BABEL_REAL" node_modules/@babel/standalone # # --------------------------------------------------------- # FROM ghcr.io/astral-sh/uv:0.13.4 AS uv # Same as pyproject.toml so that uv can pick it up and doesn't need to download a different Python version. FROM python:3.14.7-slim-bookworm@sha256:9ab8d9c8514b44f90cf0029dd42fdd7e9e211e639c8b995304cc04568dee900f AS posthog-build COPY --from=uv /uv /uvx /bin/ WORKDIR /code SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] # uv settings for Docker builds ENV UV_COMPILE_BYTECODE=1 ENV UV_LINK_MODE=copy ENV UV_PROJECT_ENVIRONMENT=/python-runtime # Install build dependencies RUN apt-get update && \ apt-get install -y --no-install-recommends \ "build-essential" \ "git" \ "libpq-dev" \ "libxmlsec1=1.2.37-2" \ "libxmlsec1-dev=1.2.37-2" \ "libffi-dev" \ "zlib1g-dev" \ "pkg-config" \ && \ rm -rf /var/lib/apt/lists/* # Install Python dependencies using cache mount for faster rebuilds # Cache ID includes libxmlsec1 version to bust cache when system library changes RUN --mount=type=cache,id=uv-libxmlsec1.2.37-2,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ --mount=type=bind,source=tools/hogli,target=tools/hogli \ # uv sync validates workspace membership even with --no-dev, so every workspace member must be # present in the build context. packages/owners-yaml is also a real install source here: owners-yaml # is a runtime dependency (stamphog's digest reads owners.yaml through it), and --no-editable # copies it into the venv so the image never depends on this bind mount's path surviving. --mount=type=bind,source=packages/owners-yaml,target=packages/owners-yaml \ --mount=type=bind,source=packages/personhog-proto,target=packages/personhog-proto \ uv sync --locked --no-dev --no-editable --no-install-project --no-binary-package lxml --no-binary-package xmlsec ENV PATH=/python-runtime/bin:$PATH \ PYTHONPATH=/python-runtime # Pre-warm the tiktoken BPE encoding cache into the image so runtime token counting reads the # blobs from disk instead of fetching them from OpenAI's blob host on first use, which fails under # restricted egress or flaky DNS. Covers the two encodings our callers resolve to: o200k_base # (gpt-4o) and cl100k_base (text-embedding-3-small). The cache is keyed by the blob URL, which is # stable for the pinned tiktoken version, so the baked files are used verbatim at runtime. ENV TIKTOKEN_CACHE_DIR=/code/.tiktoken_cache RUN mkdir -p "$TIKTOKEN_CACHE_DIR" && \ python -c "import tiktoken; [tiktoken.get_encoding(e) for e in ('o200k_base', 'cl100k_base')]" # Add in Django deps COPY manage.py manage.py COPY common/esbuilder common/esbuilder COPY common/hogvm common/hogvm/ COPY common/migration_utils common/migration_utils/ COPY posthog posthog/ COPY products/ products/ COPY ee ee/ # Copy the sourcemap-processed frontend assets and also the products.json file. The CLI injects # chunk IDs into JS before uploading maps, so the runtime JS must come from the same processed tree. COPY --from=sourcemap-upload /code/frontend/dist /code/frontend/dist COPY --from=frontend-build /code/frontend/src/products.json /code/frontend/src/products.json # Make sure we build the static files RUN SKIP_SERVICE_VERSION_REQUIREMENTS=1 STATIC_COLLECTION=1 DATABASE_URL='postgres:///' REDIS_URL='redis:///' python manage.py collectstatic --noinput # Render the wizard tool catalog from the warehouse source declarations. Web workers serve this file, # so they never import every source module, and a source module that fails to import fails the build. RUN SKIP_SERVICE_VERSION_REQUIREMENTS=1 STATIC_COLLECTION=1 DATABASE_URL='postgres:///' REDIS_URL='redis:///' python manage.py render_wizard_tool_catalog # Strip JS sourcemaps (~2.8GB) from the artifacts that ship in the final image — but ONLY when the # isolated sourcemap-upload stage confirmed a real upload to error tracking (status "uploaded"). If the # upload failed or no secret was provided, the .map files are kept so the only remaining copy isn't # lost. Done here (not in the final stage) so the bytes never enter the COPYed layers. COPY --from=sourcemap-upload /tmp/.sourcemaps-status /tmp/.sourcemaps-status RUN if [ "$(cat /tmp/.sourcemaps-status)" = uploaded ]; then \ echo "sourcemaps uploaded — stripping .map files from the image"; \ find /code/staticfiles /code/frontend/dist \( -name '*.map' -o -name '*.map.gz' -o -name '*.map.br' \) -delete; \ else \ echo "sourcemaps NOT uploaded — retaining .map files in the image"; \ fi # # --------------------------------------------------------- # FROM debian:bookworm-slim AS fetch-geoip-db WORKDIR /code SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] # Fetch the GeoLite2-City database that will be used for IP geolocation within Django. RUN apt-get update && \ apt-get install -y --no-install-recommends \ "ca-certificates" \ "curl" \ "brotli" \ && \ rm -rf /var/lib/apt/lists/* && \ mkdir share && \ ( curl -s -L "https://mmdbcdn.posthog.net/" --http1.1 | brotli --decompress --output=./share/GeoLite2-City.mmdb ) && \ chmod -R 755 ./share/GeoLite2-City.mmdb # # --------------------------------------------------------- # # Same digest as the posthog-build stage, so the interpreter matches the one the wheels were built against. FROM python:3.14.7-slim-bookworm@sha256:9ab8d9c8514b44f90cf0029dd42fdd7e9e211e639c8b995304cc04568dee900f WORKDIR /code SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"] ENV PYTHONUNBUFFERED=1 # Granian embeds libpython instead of launching the python3 CLI, so PEP 538 C-locale # coercion never runs and open() defaults to ASCII under the container's bare locale. # Force UTF-8 so file reads with non-ASCII bytes don't raise UnicodeDecodeError. ENV PYTHONUTF8=1 ENV LANG=C.UTF-8 # Install OS runtime dependencies. # Note: please add in this stage runtime dependences only! # Runtime-only shared libs: lxml/xmlsec are compiled --no-binary in the build stage (which keeps # its own -dev headers), so the final image needs the runtime .so, not the -dev headers/static libs. # libxmlsec1-openssl provides the OpenSSL crypto backend that libxmlsec1-dev used to pull in. RUN apt-get update && \ apt-get install -y --no-install-recommends --allow-downgrades \ # Upgrades the CA bundle that the pinned base image freezes. "ca-certificates" \ # The deploy chart's asset-upload hook runs in this image and downloads s5cmd with curl. "curl" \ "git" \ "libpq5" \ "libxmlsec1=1.2.37-2" \ "libxmlsec1-openssl=1.2.37-2" \ "libxml2" \ # libssl pinned to the 3.0 series (ABI-stable), not an exact version: Debian rotates # point releases out of the security archive, which breaks exact pins on uncached builds. "libssl3=3.0.*" \ "libjemalloc2" \ # Numba's OpenMP backend needs the system libgomp runtime. "libgomp1" \ # Python's mimetypes uses /etc/mime.types for artifact content types. "media-types" \ # psutil is not installed, so joblib's loky kills worker process trees with pgrep. # Operators also need ps to find a PID for py-spy. "procps" \ && \ rm -rf /var/lib/apt/lists/* # Note: no MS SQL ODBC driver is installed — the data-warehouse MSSQL source uses pymssql, which # bundles FreeTDS in its wheel and does not use msodbcsql18/unixodbc (there is no pyodbc in the tree). # Only the `node` binary is used at runtime (the plugin transpiler subprocess). Note: the dev-only # `create_channel_definitions_file` management command shells out to `npx prettier` to regenerate a # checked-in file; it is not run in this image. COPY --from=node-base /usr/local/bin/node /usr/local/bin/node RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && node --version # Install and use a non-root user. # Pin uid/gid to a fixed, host-safe value (avoid 1000, which maps to ec2-user on the nodes). RUN groupadd -g 10001 posthog && \ useradd -u 10001 -g posthog -m -d /home/posthog -s /bin/bash posthog && \ chown posthog:posthog /code USER posthog # Add the commit hash ARG COMMIT_HASH RUN echo $COMMIT_HASH > /code/commit.txt # The error tracking release this build belongs to. The CD workflow creates the release before the # build and passes its id in (see "Resolve error tracking release" in # .github/workflows/container-images-cd.yml). The Python SDK reads POSTHOG_RELEASE_ID and sends it as # $release_id on every event, so a backend exception resolves to the same release as the frontend # bundles. Empty in every other build, which the SDK treats as unset. ARG POSTHOG_RELEASE_ID ENV POSTHOG_RELEASE_ID=$POSTHOG_RELEASE_ID # Copy the Python dependencies and Django staticfiles from the posthog-build stage. COPY --from=posthog-build --chown=posthog:posthog /code/staticfiles /code/staticfiles COPY --from=posthog-build --chown=posthog:posthog /python-runtime /python-runtime ENV PATH=/python-runtime/bin:$PATH \ PYTHONPATH=/python-runtime # frontend/dist is read at runtime (Django template DIR in settings/web.py + the array.js disk # fallback in js_snippet_versioning.py), so this COPY is load-bearing. Sourced from posthog-build, # where the JS sourcemaps have already been stripped. COPY --from=posthog-build --chown=posthog:posthog /code/frontend/dist /code/frontend/dist # Ensure sourcemap-upload stage runs (the file itself is not needed in the final image). COPY --from=sourcemap-upload /tmp/.sourcemaps-processed /tmp/.sourcemaps-processed # Copy products.json from the frontend-build stage COPY --from=frontend-build --chown=posthog:posthog /code/frontend/src/products.json /code/frontend/src/products.json # Copy the GeoLite2-City database from the fetch-geoip-db stage. COPY --from=fetch-geoip-db --chown=posthog:posthog /code/share/GeoLite2-City.mmdb /code/share/GeoLite2-City.mmdb # Copy the pre-warmed tiktoken encoding cache and point tiktoken at it so token counting never # reaches out to OpenAI's blob host at runtime (see the posthog-build stage for details). COPY --from=posthog-build --chown=posthog:posthog /code/.tiktoken_cache /code/.tiktoken_cache ENV TIKTOKEN_CACHE_DIR=/code/.tiktoken_cache # Copy plugin transpiler (used by Django for site destinations/apps). The transpiler dist is a # self-contained esbuild bundle (build.mjs uses bundle:true), so only its own dist + node_modules # are needed at runtime — the full root /code/node_modules COPY was redundant. COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/dist /code/common/plugin_transpiler/dist COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/node_modules /code/common/plugin_transpiler/node_modules COPY --from=node-scripts-build --chown=posthog:posthog /code/common/plugin_transpiler/package.json /code/common/plugin_transpiler/package.json COPY --from=node-scripts-build --chown=posthog:posthog /code/products/canvas/packages/canvas_builder /code/products/canvas/packages/canvas_builder # Add in custom bin files and Django deps. COPY --chown=posthog:posthog ./bin ./bin/ # Persons SQL migration files (read by apply_persons_migrations management command for hobby deploys) COPY --chown=posthog:posthog ./rust/persons_migrations ./rust/persons_migrations/ COPY --chown=posthog:posthog manage.py manage.py COPY --chown=posthog:posthog posthog posthog/ COPY --chown=posthog:posthog ee ee/ COPY --chown=posthog:posthog common/hogvm common/hogvm/ COPY --chown=posthog:posthog common/migration_utils common/migration_utils/ COPY --chown=posthog:posthog products products/ COPY --from=posthog-build --chown=posthog:posthog /code/products/wizard/tool-catalog.json /code/products/wizard/tool-catalog.json # Stamphog ships the review engine + owners resolver from this checkout into its sandbox at # runtime (products/stamphog/backend/temporal/activities.py), so both must exist in the image as # source, the engine with its default policy files. The engine arrives with products/ above, and only packages/owners-yaml needs its own COPY. This # differs from the installation of owners-yaml into the venv as a library: the sandbox receives # files copied into a checkout, and not an import. COPY --chown=posthog:posthog packages/owners-yaml packages/owners-yaml/ RUN test -f products/stamphog/packages/pr-approval-agent/review_local.py && test -f products/stamphog/packages/pr-approval-agent/defaults/policy.yml && test -d packages/owners-yaml/owners_yaml # Generated MCP tool catalog, read at runtime from BASE_DIR by the OAuth consent page # (posthog/api/oauth/mcp_resource_scopes.py) and the tasks permission broker. The rest of # services/ is a Node build (Dockerfile.node) and deliberately stays out of this image. COPY --chown=posthog:posthog services/mcp/schema services/mcp/schema/ # Pre-compile first-party bytecode. Site-packages are already compiled (UV_COMPILE_BYTECODE=1), but # the app runs as `nobody` (bin/docker-server), which cannot write __pycache__ under the posthog-owned # /code, so without this every process compiled ~1300 first-party modules in memory at every start. # Test modules are skipped to keep the layer small. Default (timestamp) validation: one stat per # module, and a later COPY of edited .py files still takes effect. See docs/internal/django-startup-time.md. RUN /python-runtime/bin/python -m compileall -q -j 0 -x '/tests?/' \ manage.py posthog ee common/hogvm common/migration_utils products packages/owners-yaml # Validate the Playwright client library (used to drive the remote browserless service over CDP — # no browser binary ships in this image). RUN /python-runtime/bin/python -c "import playwright; print('Playwright package imported successfully')" RUN /python-runtime/bin/python -c "from playwright.sync_api import sync_playwright; print('Playwright sync API available')" # Setup ENV. ENV NODE_ENV=production # Expose container port and run entry point script. EXPOSE 8000 # Expose the port from which we serve OpenMetrics data. EXPOSE 8001 # Root is needed only so bin/docker-server can drop the app to nobody with setpriv. # nosemgrep: dockerfile.security.last-user-is-root.last-user-is-root USER root CMD ["./bin/docker"]