PostgreSQL extensions
Extensions are composition, not scale-out — how to run TimescaleDB, pgvector & co on the one Postgres you already have, including combining several in one image.
Specialized workloads — time-series telemetry, vector search, geospatial queries, graphs — trigger the
reflex to add a second database, and with it a second connection string, a second backup story, a
second thing that can be down. Elarion's positioning says otherwise: a PostgreSQL extension changes
what the one Postgres can do, not how many infrastructure pieces you run. Adding
TimescaleDB or pgvector to your
existing database is composition, exactly like the UNLOGGED L2 cache or LISTEN/NOTIFY fan-out —
not scale-out
(ADR-0056).
That posture has a corollary in both directions:
- Recipes may assume extensions. The time-series recipe freely assumes TimescaleDB; a vector-search recipe would assume pgvector the same way.
- Packages may not. There is deliberately no
Elarion.TimeSeriespackage, and no Elarion package silently requires an extension. Every Elarion package keeps working on a stockpostgresimage; an extension is always a recipe's documented prerequisite.
The one real friction is the server image, so that is what this page covers.
One extension: use the official image
An extension must be present in the server image before CREATE EXTENSION can activate it — the stock
postgres image carries neither TimescaleDB nor pgvector. A single extension usually has an official
image (timescale/timescaledb, pgvector/pgvector, apache/age); use it everywhere you run
Postgres, dev, CI, and production alike:
# docker-compose.yml — a drop-in replacement for the postgres image
services:
db:
image: timescale/timescaledb:latest-pg17Integration tests point Testcontainers at the same image:
var container = new PostgreSqlBuilder()
.WithImage("timescale/timescaledb:latest-pg17")
.Build();(Under .NET Aspire: builder.AddPostgres("db").WithImage("timescale/timescaledb", "latest-pg17").)
Activation is schema, so it belongs in the same EF migration history as everything else:
migrationBuilder.Sql("CREATE EXTENSION IF NOT EXISTS timescaledb;");Combining extensions
Needing several extensions at once is where hand-assembly starts — resolve it in this order:
- Check for an official image that already carries your combination.
timescale/timescaledb-habundles TimescaleDB + PostGIS + pgvector (and pgvectorscale), which covers the common "time-series + vectors + geo" stack with no custom image at all. - Otherwise, the canonical image is a ~10-line Dockerfile, not a build-from-source project. The
official
postgresDebian image ships with the PGDG apt repository already configured, and PGDG packages a large share of the ecosystem (postgresql-17-pgvector,postgresql-17-age, PostGIS, …); a vendor repo covers the rest (TimescaleDB):
# One image for compose, Testcontainers, and production — pin the PG major everywhere.
FROM postgres:17-bookworm
# TimescaleDB ships from its own apt repo; pgvector and Apache AGE come from PGDG,
# which the base image already has configured.
RUN apt-get update && apt-get install -y --no-install-recommends wget gnupg lsb-release \
&& echo "deb https://packagecloud.io/timescale/timescaledb/debian/ $(lsb_release -c -s) main" \
> /etc/apt/sources.list.d/timescaledb.list \
&& wget -qO- https://packagecloud.io/timescale/timescaledb/gpgkey \
| gpg --dearmor -o /etc/apt/trusted.gpg.d/timescaledb.gpg \
&& apt-get update && apt-get install -y --no-install-recommends \
timescaledb-2-postgresql-17 \
postgresql-17-pgvector \
postgresql-17-age \
&& rm -rf /var/lib/apt/lists/*
# Extensions that hook the planner/executor must be preloaded — each extension's docs
# say whether it needs this (TimescaleDB and AGE do; pgvector does not).
CMD ["postgres", "-c", "shared_preload_libraries=timescaledb,age"]CI builds this once and every environment — compose, PostgreSqlBuilder().WithImage(...) in tests,
production — runs the same image, so an extension version bump is one line reviewed in one place.
Migrations stay per-extension and unchanged (CREATE EXTENSION IF NOT EXISTS vector; /
… age;), as does any session setup an extension wants (AGE's ag_catalog search path, for
example — per its own docs).
Two rules of thumb for combinations:
- The combination pins your Postgres major to the slowest extension. Each extension supports a
range of majors and lags new releases by months (Apache AGE only reached PostgreSQL 17 with its 1.6
line); the majors available to you are the intersection. Decide the major from that intersection
before writing the Dockerfile, and prefer explicit
-pgNtags overlatesteverywhere. - An extension must stay a recipe's prerequisite, never a package's silent dependency
(ADR-0056). If a module needs AGE, that is the module's documented requirement — every Elarion
package keeps working on a stock
postgresimage.
ADR-0056 defers a framework-level easing of this (e.g. a Testcontainers helper that assembles the image from a declared extension list) until a consuming project forces the design. The Dockerfile above is the documented canonical shape until then.
The same shape for every extension
Whatever the workload, the recipe structure repeats: the entity and migration own the extension-specific column or conversion, queries use the extension's operators through raw SQL or a provider plugin, and the surrounding Elarion machinery (handlers, paging, jobs, events) neither knows nor cares. The worked example is the time-series recipe — TimescaleDB composed with bulk insert, keyset paging, scheduled retention, and client events; pgvector for similarity search or PostGIS for geospatial follow the same shape.
SQL mapping
AOT-native SQL row mapping for EF-free hosts — explicit generated mappers, injection-safe SQL interpolation, no reflection, no silent fallback.
Time series
Store telemetry on the PostgreSQL you already run — a TimescaleDB recipe composing bulk insert, keyset paging, scheduled retention, and client events.