# Stage 1: build the QueryAPIGate Console (frontend/, ADR 0001). Only its static output is carried into the final # image, so the image itself contains no Node.js. frontend/openapi.json is committed, so this stage needs no Python. # Base images by digest (ADR 0018): a rebuild uses the same bytes; Dependabot proposes each new digest as a commit. FROM node:22-slim@sha256:de170b39439eb0e5a3444ad7d60b015614a381e10fecae2e30ec9e735122f38d AS console WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci --no-audit --no-fund COPY frontend/ ./ # vite.config.ts writes to ../queryapigate/console_dist RUN npm run build # Stage 2: the Rust data plane (ADR 0030), compiled on the build machine for the image's architecture - cross-compiled, # not run under QEMU, where it would take most of an hour. Off unless started (guide 64); only the binary is carried # over. The toolchain is the one dataplane/rust-toolchain.toml names (tests/test_dataplane.py checks), on the Debian # release the image below is built on, so the binary links against the same glibc. cargo-auditable records the # crates in the binary, for the image's SBOM and scanners. FROM --platform=$BUILDPLATFORM rust:1.98.1-slim-trixie@sha256:4cd829461bd5c4d511c32e269da9cb8929223b666519d8004e35fc8d1d771ab7 AS dataplane ARG TARGETARCH ARG BUILDARCH RUN case "$TARGETARCH" in \ amd64) echo x86_64-unknown-linux-gnu > /target; gnu=x86_64-linux-gnu; deb=amd64 ;; \ arm64) echo aarch64-unknown-linux-gnu > /target; gnu=aarch64-linux-gnu; deb=arm64 ;; \ *) echo "no data plane build for $TARGETARCH" >&2; exit 1 ;; \ esac \ && if [ "$TARGETARCH" != "$BUILDARCH" ]; then \ apt-get update && apt-get install -y --no-install-recommends "gcc-$(echo $gnu | tr _ -)" "libc6-dev-$deb-cross" \ && rm -rf /var/lib/apt/lists/* \ && echo "$gnu" > /cross; \ fi \ && rustup target add "$(cat /target)" \ && cargo install cargo-auditable --version 0.7.7 --locked WORKDIR /dataplane COPY dataplane/Cargo.toml dataplane/Cargo.lock ./ COPY dataplane/src ./src # ring's C, and the link, with the target's compiler when cross-compiling; symbols stripped, the crate list kept RUN target="$(cat /target)" \ && if [ -f /cross ]; then \ gnu="$(cat /cross)"; upper="$(echo "$target" | tr a-z- A-Z_)"; \ export "CARGO_TARGET_${upper}_LINKER=$gnu-gcc" "CC_$(echo "$target" | tr - _)=$gnu-gcc"; \ fi \ && CARGO_PROFILE_RELEASE_STRIP=symbols cargo auditable build --release --locked --target "$target" \ && cp "target/$target/release/queryapigate-dataplane" /queryapigate-dataplane # Stage 3: Python (ADR 0031), from python-build-standalone: upstream's own release of CPython, statically linked and # optimized (PGO, LTO, BOLT), which runs 27-30% faster than the official python image's build of the same Python. # uv installs it and checks the download's hash, and each uv release resolves "3.13" to one exact patch release - so # pinning uv by digest pins Python, and Dependabot's bump of uv brings Python's next patch release. Only the # installation is carried over, without what the image never runs: its own pip (ensurepip's wheel stays, for the # install below), Tk and Tcl with tkinter, IDLE and turtledemo, and libpython3.13.so, which the statically linked # interpreter doesn't load. FROM ghcr.io/astral-sh/uv:0.13.6@sha256:af22a52f5eafef3d78e551599d405c02df61919d18028f0cea8eda93bc9986d0 AS uv FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 AS python COPY --from=uv /uv /usr/local/bin/uv RUN UV_PYTHON_INSTALL_DIR=/tmp/python uv python install 3.13 \ && mv /tmp/python/cpython-3.13.* /opt/python \ && cd /opt/python \ && rm -rf lib/python3.13/site-packages/pip lib/python3.13/site-packages/pip-*.dist-info bin/pip* \ lib/libtcl* lib/tcl9* lib/tk9* lib/itcl* lib/thread* lib/python3.13/tkinter lib/python3.13/idlelib \ lib/python3.13/turtledemo lib/python3.13/lib-dynload/_tkinter.* bin/idle* lib/libpython3*.so* # Stage 4: the image, on the Debian release the data plane is built on. ca-certificates, netbase and tzdata are the # three packages the official python image adds to this base; Python's ssl reads ca-certificates' /etc/ssl/certs. FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates netbase tzdata \ && rm -rf /var/lib/apt/lists/* COPY --from=python /opt/python /opt/python # The packages go in a virtualenv, first on PATH, so python and gunicorn below are its. It belongs to root and is # only read at runtime (a read-only root filesystem still works). RUN /opt/python/bin/python3 -m venv --without-pip /opt/venv ENV PATH=/opt/venv/bin:$PATH \ LANG=C.UTF-8 # Build with --build-arg WITH_H2=true to add Java and the H2 driver. ARG WITH_H2=false LABEL org.opencontainers.image.title="QueryAPIGate" \ org.opencontainers.image.description="Turn saved SQL queries into secure REST APIs and MCP tools, on PostgreSQL, MySQL, SQLite, DuckDB, ClickHouse and more." \ org.opencontainers.image.source="https://github.com/AnanthaRajuC/QueryAPIGate" \ org.opencontainers.image.licenses="Apache-2.0" ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 RUN if [ "$WITH_H2" = "true" ]; then \ apt-get update && apt-get install -y --no-install-recommends default-jre-headless \ && rm -rf /var/lib/apt/lists/*; \ fi WORKDIR /app COPY pyproject.toml README.md ./ COPY queryapigate ./queryapigate COPY --from=console /queryapigate/console_dist ./queryapigate/console_dist COPY --from=dataplane /queryapigate-dataplane /usr/local/bin/queryapigate-dataplane # The H2 driver the package bundles - not committed. One already in the build context with the pinned SHA-256 is # used as it is; otherwise it's fetched from Maven Central or a mirror of it, and the build fails unless the hash # matches (.github/scripts/fetch_h2_jar.py: the hash, the mirrors, the retries). COPY .github/scripts/fetch_h2_jar.py /tmp/fetch_h2_jar.py RUN python /tmp/fetch_h2_jar.py --dest ./queryapigate/lib && rm /tmp/fetch_h2_jar.py # Every optional extra the documentation relies on, except H2 (its Java runtime is the -h2 variant's): DuckDB is a # tier 1 database, Trino's client is all a Trino connection needs (no JVM here: Trino is a server), sqlglot enforces # allowed_tables, and redis, mcp, encryption, jwt and otel back features a deployment turns on with an environment # variable or a second command - none of them should need a custom image. pip installs # them and leaves in the same layer: the image never runs it, and the old copies of urllib3, msgpack and setuptools it # vendors are what scanners flagged (ADR 0031). An image built FROM this one gets it back with # `python -m ensurepip --default-pip`. RUN python -m ensurepip \ && python -m pip install ".[mysql,postgres,clickhouse,duckdb,mongo,trino,flow,encryption,jwt,redis,mcp,otel,aws,server]" \ "gunicorn>=26" \ && if [ "$WITH_H2" = "true" ]; then python -m pip install ".[h2]"; fi \ && python -m pip uninstall --yes pip RUN useradd --create-home app && mkdir /data && chown app /data USER app # DuckDB's httpfs extension, for s3://, gs://, r2:// and http(s):// files in a connection's allowed_paths - installed now, # so the container never downloads code at runtime (and works where it can't). RUN python -c "import duckdb; duckdb.connect().execute('INSTALL httpfs')" # queryapigate.db lives here (connections, saved queries, API keys, roles, audit log) - mount a volume to keep it. ENV QUERYAPIGATE_HOME=/data VOLUME /data # Where gunicorn listens (gunicorn_conf.py). Behind the data plane, in a second container sharing this one's network, # it's 127.0.0.1:5001 and the data plane takes 5000 - the health check below then reaches both (ADR 0030, guide 64). ENV QUERYAPIGATE_BIND=0.0.0.0:5000 EXPOSE 5000 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/health', timeout=3)"] # One worker: /metrics is per process, so a scraper reaching a container with several workers would get one at random # (and without Redis, rate limits are per process too) - scale out with more containers instead (DEPLOYMENT.md, # Scaling out). --worker-class gthread makes --threads actually take effect (gunicorn's default "sync" class # ignores it, handling one connection at a time) - required so a long-lived /events SSE connection (BACKLOG # #43) never blocks every other request to the single worker for as long as that one client stays connected. # The worker timeout must stay above QUERYAPIGATE_QUERY_TIMEOUT (30 s by default), or gunicorn would kill the worker # at the same moment the database cancels a slow query. --no-control-socket: gunicorn 26's control interface, unused # here, would otherwise write under the home folder - the only write outside /data and /tmp, so with it off the image # runs on a read-only root filesystem (the Helm chart's default, ADR 0013). The threads (QUERYAPIGATE_THREADS, 8) and # the access log (QUERYAPIGATE_ACCESS_LOG, on, to stdout) and the address (QUERYAPIGATE_BIND) come from # queryapigate/gunicorn_conf.py, not flags here: a flag would beat the setting, and changing one would mean replacing # this whole command. CMD ["gunicorn", "--workers", "1", "--worker-class", "gthread", \ "--timeout", "120", "--graceful-timeout", "30", "--no-control-socket", \ "-c", "python:queryapigate.gunicorn_conf", \ "queryapigate.app:create_app()"]