Sign inSign up

alexeye/postgres-lakehouse

By alexeye

•Updated 4 days ago

PostgreSQL with the analytics layer - Parquet, S3, Azure Blob, Delta, Iceberg via pg_duckdb. Not Azu

Image
0

1.3K

alexeye/postgres-lakehouse repository overview

⁠PostgreSQL Lakehouse

Docker Hub Docker Pulls Image Size

PostgreSQL with the analytics layer attached. Query and write Parquet, CSV and JSON on the local filesystem, on S3 and on Azure Blob Storage; read Delta Lake and Iceberg tables — all from SQL, joined directly against live Postgres tables.

⁠This image is not Azure-compatible, on purpose

It is built on alexeye/postgres-azure-flex⁠ for code reuse, then adds extensions Azure Database for PostgreSQL does not offer.

If your code deploys to Azure, use postgres-azure-flex instead. This image will happily let you write SQL that fails in production.

Use this one for on-premises deployments, and for local analytics work where the target is your own infrastructure.

⁠Quick Start

docker run -d \
  --name postgres \
  -e POSTGRES_PASSWORD=mysecretpassword \
  -p 5432:5432 \
  alexeye/postgres-lakehouse:18
-- Write a Postgres table out as Parquet
COPY (SELECT * FROM orders) TO '/data/orders.parquet' (FORMAT parquet);

-- Read it back
SELECT count(*) FROM read_parquet('/data/orders.parquet') r;

-- Read a CSV sitting on disk
SELECT r['name'], r['amount'] FROM read_csv('/data/input.csv') r;

-- ...and the point of the whole image: join lake data to live Postgres rows
SELECT c.name, sum(r['amount']::numeric) AS total
FROM read_parquet('s3://lake/orders/*.parquet') r
JOIN customers c ON c.id = r['customer_id']::bigint
GROUP BY c.name;

⁠Object Storage

⁠S3 (and MinIO, R2, any S3-compatible endpoint)

httpfs is statically linked, so S3 works with no downloads and no internet access beyond your bucket.

SELECT duckdb.create_simple_secret(
    type     := 'S3',
    key_id   := 'AKIA...',
    secret   := '...',
    region   := 'eu-west-1');

COPY (SELECT * FROM events) TO 's3://my-bucket/events/2026-09.parquet' (FORMAT parquet);
SELECT count(*) FROM read_parquet('s3://my-bucket/events/*.parquet') r;

For MinIO or another self-hosted endpoint, add url_style := 'path', endpoint := 'minio:9000' and use_ssl := 'false'.

⁠Azure Blob Storage
SELECT duckdb.create_azure_secret('DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...');
SELECT count(*) FROM read_parquet('azure://container/path/*.parquet') r;
⁠Delta Lake and Iceberg (read)
SELECT count(*) FROM delta_scan('s3://lake/delta/orders') r;
SELECT count(*) FROM iceberg_scan('s3://lake/iceberg/orders') r;

⁠Works With No Internet

DuckDB normally downloads its extensions on first use, which is useless on an air-gapped host and makes builds unreproducible. This image bakes them in at build time instead:

ExtensionHow it ships
httpfs (S3, HTTP), parquet, json, icuStatically linked into pg_duckdb
azure, delta, iceberg, awsBaked into /opt/duckdb/extensions at build time

duckdb.extension_directory points there, and the directory stays writable — a host with network access can still install more DuckDB extensions at runtime.

⁠Local Filesystem

Two ways to read server-side files:

-- DuckDB — fast, handles Parquet/CSV/JSON, supports globbing
SELECT * FROM read_csv('/data/*.csv') r;

-- file_fdw — a plain foreign table, if you want a persistent relation
CREATE SERVER files FOREIGN DATA WRAPPER file_fdw;
CREATE FOREIGN TABLE access_log (ip text, ts timestamptz, path text)
    SERVER files OPTIONS (filename '/data/access.csv', format 'csv');

Mount your data in with -v /host/data:/data.

⁠⚠️ TimescaleDB

TimescaleDB is installed but deliberately not preloaded, and you must not add it.

Preloading timescaledb alongside pg_duckdb segfaults the backend the moment DuckDB scans a Postgres heap table. It takes down the postmaster and forces crash recovery.

Verified by isolation:

pg_duckdb only                    -> ok
pg_duckdb + age                   -> ok
pg_duckdb + pg_cron               -> ok
pg_duckdb + pg_stat_statements    -> ok
pg_duckdb + timescaledb           -> SIGSEGV

Note that generate_series() does not reproduce it — the query has to touch a real table.

test.sh asserts the shipped default excludes timescaledb. If you need TimescaleDB, use postgres-azure-flex.

⁠RabbitMQ and Other Brokers

There is no AMQP extension in this image, on purpose.

No maintained one exists (pg_amqp's last commit was June 2024 and it needs a source build), but that's the smaller objection. Publishing to a broker from inside a Postgres backend blocks that backend, doesn't roll back when the transaction does, and has no retry. A slow broker becomes a database outage.

Use the transactional outbox pattern instead. Every piece you need is already here:

  1. Write to an outbox table in the same transaction as your business data — atomic, rolls back correctly.
  2. wal2json streams the changes via logical decoding. This image ships wal_level = logical and allowlists wal2json, so a slot works with no restart.
  3. A relay process publishes to RabbitMQ with retries and at-least-once delivery.

See examples/outbox/⁠ for the schema and a runnable Postgres + RabbitMQ compose file. The relay itself is deliberately not shipped — use Debezium, or a small service of your own.

⁠What's Inherited

The complete Azure parity set from the base image is still here and still works: pgvector, PostGIS, Apache AGE, pg_cron, pgaudit, pglogical, wal2json, pg_partman, pg_repack, orafce, plus the contrib extensions. See the base image documentation⁠.

⁠Added On Top

ExtensionPurpose
pg_duckdbThe analytics engine — Parquet, CSV, JSON, S3, Azure Blob, Delta, Iceberg
file_fdwForeign tables over server-side files
httpSynchronous outbound HTTP from SQL
plpython3uUntrusted Python. Installed but not created — it grants superusers code execution. CREATE EXTENSION plpython3u; deliberately.

⁠Configuration

SettingValueWhy
shared_preload_librariespg_stat_statements,pg_cron,age,pg_duckdbtimescaledb removed — see the warning above
wal_levellogicalSo the outbox pattern works without a restart. Set to replica if you don't use logical replication.
duckdb.extension_directory/opt/duckdb/extensionsThe baked, offline-capable extensions

⁠Docker Compose

services:
  postgres:
    image: alexeye/postgres-lakehouse:18
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      # PG 18+ keeps data in a major-version subdirectory
      - pgdata:/var/lib/postgresql
      - ./data:/data          # your Parquet / CSV files
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
    restart: unless-stopped

volumes:
  pgdata:

⁠Environment Variables

VariableDefaultDescription
POSTGRES_PASSWORD-Required. Superuser password
POSTGRES_USERpostgresSuperuser username
POSTGRES_DBpostgresDefault database name

⁠Security

Scanners will report a non-zero CVE count. The honest breakdown:

SourceCan we fix it?
openssl, perl, libtiff — Debian has no fix yetNo. Already at the newest version Debian ships.
The base's gosu binary (Go stdlib) — 22 findingsNo. Go toolchain CVEs; upstream must rebuild. gosu 1.19 is the newest release and is already what ships. It runs once at startup to exec the server as the postgres user and never opens a socket, so the vulnerable HTTP/TLS paths are unreachable.
Debian packages with a fix availableYes — applied at build time. Currently zero.

This image is larger than the base, so it carries the base's findings plus whatever DuckDB's bundled libraries add. What we do:

  • Debian security updates applied at build time, in both this image and its base.
  • Rebuilt whenever the base image publishes, so patches flow through without a separate schedule.
  • Scanned in CI before pushing, split into fixable (actionable, should be empty) and everything (informational).
  • Provenance and SBOM attestations published with each image.
⁠The gosu findings are marked not-affected (VEX)

Each image ships an OpenVEX⁠ document at /usr/local/share/vex/gosu-go-stdlib.vex.json declaring those 22 gosu findings not_affected / vulnerable_code_not_in_execute_path.

That is not hand-waving. govulncheck — the Go team's reachability analyser — run against the exact binary from the image reports:

$ govulncheck -mode=binary ./gosu
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.

The vulnerable code is linked in but never called. gosu parses argv, calls setuid/setgid and execs; it opens no sockets and reads no untrusted input.

Docker Scout shows the justification automatically, but only applies it if you trust the author — --vex-author defaults to Docker's own statements. That opt-in is deliberate: no publisher should be able to silence your scanner unilaterally. To have it take effect:

docker scout cves   --vex-author "AlexeyEvlampiev <[email protected]>"   alexeye/postgres-lakehouse:18

Verified: that command reports 0C 0H 0M 0L where the default reports 22.

The claim is re-checked whenever the base image changes — see vex/generate.py⁠ for the procedure. If gosu ever gains a reachable vulnerability, the document is withdrawn.

docker scout cves --only-severity critical,high --only-fixed alexeye/postgres-lakehouse:18

Findings there are a genuine bug — please open an issue.

⁠Deployment notes

pg_duckdb executes queries against whatever object storage you point it at, and duckdb.enable_external_access is on by default. If you expose this database to untrusted users, constrain it:

# Restrict which local paths DuckDB may read
duckdb.allowed_directories = '/data'
# Or disable filesystem access entirely
duckdb.disabled_filesystems = 'LocalFileSystem'

Credentials created with duckdb.create_simple_secret() are visible to superusers. plpython3u is installed but deliberately not created — creating it grants superusers arbitrary code execution on the host.

⁠Image Details

  • Base image: alexeye/postgres-azure-flex:18
  • Architectures: linux/amd64, linux/arm64
  • pg_duckdb donor: pinned by manifest digest — never a rolling tag
  • CI: built, then tested, then pushed. Rebuilds automatically whenever the base image publishes.
  • Attestations: max-mode provenance + SBOM
  • License: Apache License 2.0
  • Source: github.com/AlexeyEvlampiev/custom-docker⁠

Tag summary

Content type

Image

Digest

sha256:24e2aad60…

Size

583.5 MB

Last updated

4 days ago

docker pull alexeye/postgres-lakehouse