Self-hosted service
supabase/storage avatar
supabase/storage

Supabase Storage: object storage with its metadata in Postgres

S3 compatible object storage service that stores metadata in Postgres

1,338 stars314 forksTypeScriptApache-2.0

At a glance

What is it?
The Supabase Storage engine serves objects over HTTP, TUS, the S3 API and the Iceberg REST catalog, keeps the metadata in Postgres where Row Level Security policies do the authorization, and leaves the bytes themselves to an S3-compatible backend.
Who is it for?
Supabase Storage fits a Supabase project that wants object storage with the same auth and the same row level security model as the rest of its database, and it fits a data platform team that needs the Iceberg REST catalog on the same infrastructure. It is a poor fit if you want a self-contained object store, since the bytes always live in an S3-compatible backend and the metadata always lives in Postgres, so there is no single system to run.
Can I use it commercially?
Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The split that defines the service

Supabase Storage is a scalable, lightweight object storage service, and the description in the repository metadata says it in one line: an S3 compatible object storage service that stores metadata in Postgres.

That split is the whole architecture. The bytes are not in Postgres, they are in an S3-compatible backend, and the local development environment makes that explicit by pointing the storage service at a separate endpoint:

yaml
      STORAGE_BACKEND: s3
      STORAGE_S3_BUCKET: supa-storage-bucket
      STORAGE_S3_ENDPOINT: http://rustfs:9000
      STORAGE_S3_FORCE_PATH_STYLE: "true"
      STORAGE_S3_REGION: us-east-1
      AWS_ACCESS_KEY_ID: supa-storage
      AWS_SECRET_ACCESS_KEY: secret1234

The endpoint is a RustFS instance, an S3-compatible server, and force path style is turned on because that is what a non-AWS S3 implementation expects. So the storage engine is the layer that understands buckets, objects, keys and uploads, and the S3 backend is where the data physically lives.

The metadata side is Postgres, and the compose file points the service at a tenant database and a connection pooler:

yaml
      DATABASE_URL: postgres://postgres:postgres@tenant_db:5432/postgres
      DATABASE_POOL_URL: postgresql://postgres:postgres@pg_bouncer:6432/postgres

Two connection strings, one direct and one pooled, and the service waits for the database to be healthy and for the pooler to have started before it comes up.

The reason to split it this way is the authorization model, which is the second of the listed features. Authorization rules are written as Postgres Row Level Security policies, so the same policies that govern a table in your database govern the objects in a bucket, and a user with a JWT is filtered by the same rules the database already applies. That is the reason to build a storage service this way rather than bolt storage onto an existing one, and it is also why the project calls the service extremely lightweight and performant.

Four protocols on one object store

The supported protocol list is the feature that makes the service more than a file upload endpoint.

HTTP and REST is the first, and it is what the client library uses. TUS resumable upload is the second, with the upload paths configured by the TUS_URL_PATH and TUS_URL_EXPIRY_MS settings, so an interrupted upload can resume rather than restart. The S3 compatible API is the third, with its own credentials in S3_PROTOCOL_ACCESS_KEY_ID and S3_PROTOCOL_ACCESS_KEY_SECRET, which means existing S3 tooling can point at the service. And the Iceberg REST catalog is the fourth, configured with ICEBERG_BUCKET_DETECTION_MODE, ICEBERG_WAREHOUSE and ICEBERG_CATALOG_URL, which is the open table format path.

That last one is the most consequential. An Iceberg REST catalog is how a lakehouse table format locates its metadata, and serving it from the same object storage service means a data platform can run its tables on the same infrastructure as the application database, with the same auth and the same RLS policies, instead of running a separate catalog service and a separate storage bucket.

There is also image transformation, configured with IMAGE_TRANSFORMATION_ENABLED, IMGPROXY_URL and IMGPROXY_REQUEST_TIMEOUT, and the compose file points those at an imgproxy instance. So resizing and format conversion of stored images is delegated to imgproxy rather than implemented in the storage engine.

The upload limits are two-tiered. UPLOAD_FILE_SIZE_LIMIT is 524288000, which is 500MB, and UPLOAD_FILE_SIZE_LIMIT_STANDARD is 52428800, which is 50MB, so there is a larger path and a standard path. UPLOAD_SIGNED_URL_EXPIRATION_TIME is 120 seconds, which is the window on a signed upload URL, and that is a value worth thinking about before you set it to something else, since it is the time a client has to complete the upload.

Running it locally, with Postgres and an S3 backend

The development setup in the README is four commands. First you copy the two environment files:

bash
cp .env.sample .env && cp .env.test.sample .env.test

Then you start the infrastructure and the server:

bash
# this sets up a postgres database and postgrest locally via docker
npm run infra:restart
# Start the storage server
npm run dev

And the server should now be running at http://localhost:5000/.

There is a third environment file in the repository, .env.acceptance.sample, alongside .env.sample and .env.test.sample, so there are three configurations: normal development, test, and acceptance. The acceptance suite has its own scripts in the package manifest, including run-managed-local.ts and a variant with a managed runtime, and a separate typecheck target with its own tsconfig.

The verification step in the README uses the API directly rather than the client library, and it creates a bucket:

bash
# insert a bucket named avatars
curl --location --request POST 'http://localhost:5000/bucket' \
--header 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \
--header 'Content-Type: application/json' \
--data-raw '{
    "name": "avatars"
}'

The token in that example is a JWT with a service_role role, which is the Supabase convention, and it is signed with the AUTH_JWT_SECRET from the environment. That same secret appears in the .env.sample file and in the compose file, both with the same placeholder value, which tells you that the development configuration is meant to be a throwaway and that you have to change it for anything real.

Multi-tenancy, cluster discovery, and the configuration surface

The .env.sample file is a good map of what the service actually does, because every entry is a knob with a reason.

The server section sets the host, the port 5000, an admin port 5001, the keep-alive and headers timeouts, and a SERVER_REGION. The request and response section sets the trace header, the ETag handling headers, and a set of caching directives: RESPONSE_S_MAXAGE, RESPONSE_STALE_WHILE_REVALIDATE and RESPONSE_STALE_IF_ERROR, which together control how long a cached response stays fresh, how long a stale response may be served while revalidating, and how long a stale response may be served when the origin errors. Those three together are what let a storage service keep serving cached objects when the database is briefly unavailable.

The multi-tenancy section is commented out by default with MULTI_TENANT=true, and it carries its own database URLs, DATABASE_MULTITENANT_URL and DATABASE_MULTITENANT_POOL_URL, pointing at a different port. There is a REQUEST_X_FORWARDED_HOST_REGEXP that matches a host pattern, so the tenant is derived from the hostname the request arrived on, and SERVER_ADMIN_API_KEYS for administrative access. The repository has a separate docker-compose-multi-tenant.yml, which is how the multi-tenant arrangement is exercised locally.

The cluster discovery section is for running on ECS and EKS. CLUSTER_DISCOVERY_TIMEOUT_MS is the deadline for startup and for each poll, and the comment says startup failure exits the process, so you can raise it for slow initial listings. CLUSTER_DISCOVERY_POLL_INTERVAL_MS is the base polling interval, and the polls run serially with jitter and back off on failure. CLUSTER_DISCOVERY_ECS_MAX_RPS is a request budget for ListTasks calls per second across the task family and the workers, with the comment that startup calls and retries can exceed it.

The detail about the AWS_MAX_ATTEMPTS precedence over the shared AWS profile setting is the sort of thing that only shows up in a configuration file, and it matters for anyone hitting ECS API throttling in a large deployment.

The build, the migrations, and the test matrix

The service is TypeScript run with tsx in development and compiled to dist for production. The dev script is tsx watch src/start/server.ts piped through pino-pretty, so the server reloads on change and the logs are pretty-printed. The build script runs tsc with noEmit, then node ./build.js and resolve-tspaths, and the start script runs node dist/start/server.js with NODE_ENV=production.

The migrations are a first-class part of the project, not an afterthought. There is a migrations/ directory, a migration:run script using a dedicated migrate-call entry point, a migration:test-idempotency script, and a migrations:types script. The compose file sets DB_INSTALL_ROLES to true with a comment that you set it to false if you want to manage roles yourself, and the Dockerfile copies the migrations directory into the final image separately from the build artefacts, which is how the migration files reach the running container.

There is also a pprof script pointing at a client profiler, which is a reasonable thing to ship in a service that talks to Postgres and S3 over the network.

The test setup separates unit from integration, with two Vitest configs, vitest.unit.config.ts and vitest.integration.config.ts, plus watch and coverage variants for each. The integration run needs infrastructure, so the CI variant is test:integration:ci, which restarts the infra, imports dummy data and then runs the integration tests. The top-level test script is the whole sequence: unit tests, then infra:restart, then test:dummy-data, then the integration tests. There is a dedicated script for importing dummy data, so the integration suite runs against a database with known contents rather than an empty one.

Linting is Biome plus Prettier, with a biome.jsonc at the root, and the lint script runs both the check and the format check. The package manager is npm at 11.12.1, and the Dockerfile enforces that with a check script, scripts/ensure-npm-version.cjs, run during the build.

Editorial conclusion

Supabase Storage fits a Supabase project that wants object storage with the same auth and the same row level security model as the rest of its database, and it fits a data platform team that needs the Iceberg REST catalog on the same infrastructure. It is a poor fit if you want a self-contained object store, since the bytes always live in an S3-compatible backend and the metadata always lives in Postgres, so there is no single system to run. Before adopting, read the storage guides for your use case, and decide whether the S3 or the HTTP interface is your primary one, because the docker-compose setup for the local environment wires the storage service to a separate S3 endpoint and a separate Postgres instance.

Frequently asked questions

What is Supabase Storage and where does it keep the data?

It is a scalable, lightweight object storage service that stores its metadata in Postgres and keeps the objects themselves in an S3-compatible backend. The repository description calls it an S3 compatible object storage service that stores metadata in Postgres, and the local compose file points it at a RustFS S3 endpoint.

Which protocols does Supabase Storage support?

HTTP and REST, TUS resumable upload, the S3 compatible API and the Iceberg REST catalog. The Iceberg support is configured with ICEBERG_BUCKET_DETECTION_MODE, ICEBERG_WAREHOUSE and ICEBERG_CATALOG_URL, and the S3 protocol has its own access key settings.

How does Supabase Storage handle authorization?

Authorization rules are written as Postgres Row Level Security policies, so the same database policies govern bucket objects. The service authenticates with a JWT, using AUTH_JWT_SECRET and AUTH_JWT_ALGORITHM, and the multi-tenant mode derives the tenant from the request host using REQUEST_X_FORWARDED_HOST_REGEXP.

How do I run Supabase Storage locally?

Copy .env.sample to .env and .env.test.sample to .env.test, then run npm run infra:restart to set up Postgres and PostgREST via Docker, and npm run dev to start the storage server on port 5000. The Docker Compose file also defines a multi-tenant variant, docker-compose-multi-tenant.yml.

Official sources

  1. License: Apache-2.0
  2. Project website
  3. README
  4. Releases
  5. supabase/storage on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/supabase-storage.svg)](https://hysenlabs.com/projects/supabase-storage)

Was this useful?