Custom Postgres Extensions
Build a custom Docker image with additional Postgres extensions.
Overview#
The supabase/postgres image includes a curated set of extensions that are compiled in at build time. There is no runtime mechanism to install a compiled native .so extension into a running container: you cannot apk add or apt-get install an extension package, because the image is built with Nix and its extension set is fixed when the image is produced.
To add a native extension that Supabase Postgres doesn't provide, you have to build your own image. This guide walks through that end to end for the current Alpine-based images, using pg_uuidv7 as the running example.
If your extension is written in pure SQL or a trusted procedural language, you don't need a custom image at all. Use pg_tle, which is already bundled and preloaded - run CREATE EXTENSION pg_tle; and install your extension through it.
The proper way to add an extension is to compile it into the image's Nix build. That's the most robust route, but it requires Nix, building the image from source, and maintaining a fork. The rest of this guide describes a simpler alternative: build your extension in an ordinary Docker builder and layer it onto the published image. The approach can work for many standard PGXS extensions - but your mileage may vary, and the constraints in the next section are the trade-off.
Supabase builds, tests, and maintains the official supabase/postgres images and their bundled extensions. A custom image built by following this guide is unofficial and unsupported - it's not guaranteed to work, and may break with future changes to the base image. Testing, quality assurance, and ongoing maintenance are your responsibility.
Why Postgres images need special handling#
The base image is Alpine Linux, but the Postgres binaries and every bundled extension live in a /nix store:
- The runtime is
glibc, notmusl. The Nix-builtpostgresand its extensions are linked againstglibc. If you compile an extension with Alpine's native toolchain (apk add build-base), it linksmusland fails to load atCREATE EXTENSIONwith an error likelibc.musl-aarch64.so.1: cannot open shared object file. You must build in aglibcenvironment. - Match the major version, and keep the builder's
glibcno later than the image's. Extensions are ABI-stable across an entire Postgres major version. However, an extension built against a newerglibcthan the runtime provides will fail to load (version 'GLIBC_2.xx' not found). The Supabase image currently shipsglibc 2.40, so this guide builds on Debian 12 (postgres:17-bookworm,glibc 2.36), which stays safely below it. Avoid the defaultpostgres:17- it's currently Debian 13 andglibc 2.41. - The module directory is redirected. The running
postgresis a wrapper script that overrides its library directory via aNIX_PGLIBDIRenvironment variable. Your compiled.somust be installed into that directory - not the pathpg_config --pkglibdirreports. This is why you can't follow an extension's upstream install instructions which typically usemake installor copy topg_config --pkglibdir.
How the image is versioned
The tag starts with the Postgres version followed by Supabase's own release numbers. Extension ABI is stable across a major version - but if a future Supabase Postgres image bumps its glibc, re-check the rule in point 2. You can read the image's glibc version with:
1docker run --rm --entrypoint sh supabase/postgres:17.6.1.136 -c \2 'ls -d /nix/store/*glibc-2.*-* 2>/dev/null | grep -oE "glibc-2\.[0-9]+" | sort -uV | tail -1'Prerequisites#
- Docker installed and running
- The exact
supabase/postgrestag your deployment uses. For example,supabase/postgres:17.6.1.136 - Extension source that builds with standard PGXS
Always build against the same major version you run, and rebuild your custom image whenever you upgrade the base image.
Build the extension#
Use a multi-stage build: a glibc builder stage (postgres:<major>-bookworm) to compile, and the Supabase image as the runtime stage that installs the artifacts into the correct Nix locations.
Step 1: Write the Dockerfile#
The example below builds pg_uuidv7 cloned from its Git repository.
Dockerfile
1# syntax=docker/dockerfile:12ARG SUPABASE_POSTGRES_TAG=17.6.1.1363ARG PG_MAJOR=1745# --- Builder: glibc image matching the Postgres major version ---6FROM postgres:${PG_MAJOR}-bookworm AS builder7ARG PG_MAJOR8ARG EXT_VERSION=v1.7.09RUN apt-get update && apt-get install -y --no-install-recommends \10 build-essential git ca-certificates postgresql-server-dev-${PG_MAJOR} \11 && rm -rf /var/lib/apt/lists/*12WORKDIR /src13RUN git clone --depth 1 --branch ${EXT_VERSION} https://github.com/fboulnois/pg_uuidv7 .14RUN make1516# --- Runtime: Supabase Alpine image ---17FROM supabase/postgres:${SUPABASE_POSTGRES_TAG}18USER root19COPY --from=builder /src/pg_uuidv7.so /tmp/20COPY --from=builder /src/pg_uuidv7.control /tmp/21COPY --from=builder /src/sql/ /tmp/ext-sql/22# The running postgres wrapper redirects its module dir via NIX_PGLIBDIR.23# Install the .so there; install control/SQL into pg_config --sharedir.24RUN set -eux; \25 PLUGIN_DIR="$(grep -E '^export NIX_PGLIBDIR' /usr/bin/postgres | sed -E "s/.*'([^']*)'.*/\1/")"; \26 SHARE_DIR="$(pg_config --sharedir)/extension"; \27 install -m 755 /tmp/pg_uuidv7.so "$PLUGIN_DIR/"; \28 install -m 644 /tmp/pg_uuidv7.control "$SHARE_DIR/"; \29 install -m 644 /tmp/ext-sql/*.sql "$SHARE_DIR/"; \30 rm -rf /tmp/pg_uuidv7.* /tmp/ext-sqlThe same Dockerfile structure generally works for your own extensions. However, when authoring a new extension from scratch, it's best to use the Nix build instead (refer to Build the extension into the Nix image below).
Handle dependencies beyond the standard library#
Copying a .so onto the image works cleanly only when its sole runtime dependency is the C library (libc.so.6) - that's the image's glibc, already loaded in the postgres process, so it resolves automatically. Any other shared library the extension links (libcurl, OpenSSL, etc.) must also be present and loadable at runtime.
The build happens inside Docker - check the compiled .so in the builder stage rather than on your host:
1docker build --target builder -t ext-builder .2docker run --rm --entrypoint sh ext-builder -c 'readelf -d /src/*.so | grep NEEDED'- Only
libc.so.6andld-linux-*- the Dockerfile above is sufficient (pg_uuidv7is this case). - Anything else listed - handle those dependencies with one of the options below.
Recommended: statically link the extra dependencies into the .so, so its only remaining NEEDED is libc.so.6. After building, re-run the check above and confirm only libc.so.6 and ld-linux-* remain.
A few things make this more involved than it sounds:
- The distribution's own static archive is often not enough. A feature-rich
libcurl.a, for example, includes many dependencies whose static archives aren't all installable - so you typically build a minimal static version of the dependency from source and link that. - PGXS may ignore the extension's
CFLAGS. Pass extra include paths viaPG_CPPFLAGSon themakecommand line rather than editing the Makefile. - Static linking has limits. It won't help a dependency that
dlopens plugins at runtime, and linking a library that's also loaded by another extension (two copies of OpenSSL in one process, say) can clash. - Some extensions hard-code their own name, so you can't rename one to sidestep a collision with a bundled extension.
When static linking isn't practical, use the Nix path, which resolves every dependency against the image's own libraries automatically.
Step 2: Build the image#
Build against the tag you run in production so the runtime and major version match:
1docker build \2 --build-arg SUPABASE_POSTGRES_TAG=17.6.1.136 \3 --build-arg PG_MAJOR=17 \4 -t supabase-postgres-custom:17.6.1.136 \5 .Step 3: Use the image in your stack#
Point the db service at your custom image in docker-compose.yml:
docker-compose.yml
1db:2 image: supabase-postgres-custom:17.6.1.1363 # ...leave the rest of the service definition unchangedThen recreate the database service so it picks up the new image:
1sh run.sh recreate dbStep 4: Enable the extension#
The Supabase postgres role is intentionally not a superuser, and extension creation is gated by supautils. A native extension that isn't on the supautils.privileged_extensions allow-list can only be created by the supabase_admin superuser. You have two options.
Option A: Allow the postgres role to create it (no rebuild)#
Append your extension to the allow-list in a custom supautils configuration. The Postgres 17 image loads any .conf file from /etc/postgresql-custom/conf.d/.
1docker exec supabase-db bash -c '2CUR=$(psql -U postgres -tAc "show supautils.privileged_extensions" | tr -d "\n")3cat > /etc/postgresql-custom/conf.d/99-custom-extensions.conf <<EOF4supautils.privileged_extensions = '"'"'$CUR, pg_uuidv7'"'"'5EOF'Restart to apply, then create it as the regular postgres role:
1sh run.sh restart db2docker compose exec db psql -U postgres -c "CREATE EXTENSION pg_uuidv7;"/etc/postgresql-custom/ is on the db-config named volume, so this change persists across restarts. Read Custom Postgres configuration for more on the conf.d/ mechanism.
Option B: Enable it as a superuser#
The image's init scripts run as supabase_admin (a superuser) on first boot. Drop a SQL file into the init directory to create the extension automatically for new databases:
docker-compose.yml
1db:2 volumes:3 # ...keep the existing mounts (the data dir, roles.sql, etc.) and add:4 - ./volumes/db/pg_uuidv7.sql:/docker-entrypoint-initdb.d/migrations/99-pg_uuidv7.sql:Zvolumes/db/pg_uuidv7.sql
1CREATE EXTENSION IF NOT EXISTS pg_uuidv7;Init scripts only run when the data directory (./volumes/db/data) is empty, that is on first initialization. For an already-initialized database, connect as supabase_admin once and run CREATE EXTENSION manually.
Verify the extension#
1docker compose exec db psql -U postgres \2 -c "CREATE EXTENSION IF NOT EXISTS pg_uuidv7;" \3 -c "SELECT uuid_generate_v7();"1uuid_generate_v72--------------------------------------3 019f897b-1003-7175-b9b5-be46fc0c39904(1 row)Extensions that have to be preloaded#
If your extension must be listed in shared_preload_libraries, add it with the same conf.d/ mechanism. The conf.d/ include runs after the baked-in setting, so restate the current value plus your library:
1docker exec supabase-db bash -c '2CUR=$(psql -U postgres -tAc "show shared_preload_libraries" | tr -d "\n")3cat > /etc/postgresql-custom/conf.d/99-preload.conf <<EOF4shared_preload_libraries = '"'"'$CUR, custom_bgworker'"'"'5EOF'6sh run.sh restart dbshared_preload_libraries has no append syntax. Include the full existing list plus your library, or you'll disable the extensions Supabase relies on.
Build the extension into the Nix image#
The Docker approach above layers a separately-compiled .so onto the published image. The most reliable way is to add the extension to Supabase's own Nix build, so it's compiled with the exact same toolchain as everything else in the image. This option sidesteps the libc/ABI concerns entirely and is how the bundled extensions are built. It requires Nix, building the image from source, and maintaining a fork of supabase/postgres.
Rough guide to choosing:
- One-off extension on the stock image, minimal tooling - the Docker approach in this guide.
- Bulletproof ABI match, want it merged upstream, or it needs preload /
supautilswiring done "the Supabase way" - the Nix build below.
This guide intentionally doesn't teach Nix. The authoritative, maintained instructions live in the supabase/postgres repository:
- Adding a new extension package - the main walkthrough (C/C++ and Rust/pgrx patterns, where to register the extension, generating hashes)
- Creating a
pgrxextension - for Rust extensions - Building Postgres and the full
nix/docsdirectory
Keeping the image up to date#
Because your image is pinned to a specific supabase/postgres tag and depends on that build's internal Nix paths, treat it as coupled to the base image:
- Rebuild with the new
SUPABASE_POSTGRES_TAG(and matchingPG_MAJOR) every time you upgrade Postgres. - Re-test
CREATE EXTENSIONafter each rebuild. A change to the base OS or Nix layout - as happened when the images moved from Debian to Alpine - can require adjusting the build.