posthog Docker & Self-Hosted Deployment

GitHub

Hedgehog: PostHog is the leading platform for building self-driving products. Our developer tools โ€“ AI observability, analytics, session replay, flags,...

1. Pull Image (DOCKERHUB) docker pull PostHog/posthog:latest
2. Docker Run (1-Click Launch) docker run -d --name posthog -p 8000:8000 --restart always posthog/posthog:latest
3. Podman Run podman run -d --name posthog -p 8000:8000 --restart always posthog/posthog:latest
4. Skopeo Inspect (Zero-Pull Manifest) skopeo inspect docker://docker.io/posthog/posthog:latest
5. ORAS Pull (OCI Artifact) oras pull posthog/posthog:latest
Dockerfile
#
# 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<X.Y.Z>/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"]