PostgreSQL with the analytics layer - Parquet, S3, Azure Blob, Delta, Iceberg via pg_duckdb. Not Azu
1.3K
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-flexinstead. 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.
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;
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'.
SELECT duckdb.create_azure_secret('DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...');
SELECT count(*) FROM read_parquet('azure://container/path/*.parquet') r;
SELECT count(*) FROM delta_scan('s3://lake/delta/orders') r;
SELECT count(*) FROM iceberg_scan('s3://lake/iceberg/orders') r;
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:
| Extension | How it ships |
|---|---|
httpfs (S3, HTTP), parquet, json, icu | Statically linked into pg_duckdb |
azure, delta, iceberg, aws | Baked 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.
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 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.
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:
wal2json streams the changes via logical decoding. This image ships wal_level = logical and allowlists wal2json, so a slot works with no restart.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.
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.
| Extension | Purpose |
|---|---|
| pg_duckdb | The analytics engine — Parquet, CSV, JSON, S3, Azure Blob, Delta, Iceberg |
| file_fdw | Foreign tables over server-side files |
| http | Synchronous outbound HTTP from SQL |
| plpython3u | Untrusted Python. Installed but not created — it grants superusers code execution. CREATE EXTENSION plpython3u; deliberately. |
| Setting | Value | Why |
|---|---|---|
shared_preload_libraries | pg_stat_statements,pg_cron,age,pg_duckdb | timescaledb removed — see the warning above |
wal_level | logical | So the outbox pattern works without a restart. Set to replica if you don't use logical replication. |
duckdb.extension_directory | /opt/duckdb/extensions | The baked, offline-capable extensions |
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:
| Variable | Default | Description |
|---|---|---|
POSTGRES_PASSWORD | - | Required. Superuser password |
POSTGRES_USER | postgres | Superuser username |
POSTGRES_DB | postgres | Default database name |
Scanners will report a non-zero CVE count. The honest breakdown:
| Source | Can we fix it? |
|---|---|
openssl, perl, libtiff — Debian has no fix yet | No. Already at the newest version Debian ships. |
The base's gosu binary (Go stdlib) — 22 findings | No. 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 available | Yes — 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:
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.
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.
alexeye/postgres-azure-flex:18linux/amd64, linux/arm64Content type
Image
Digest
sha256:24e2aad60…
Size
583.5 MB
Last updated
4 days ago
docker pull alexeye/postgres-lakehouse