Storage Backends
club uses three independent storage layers, each behind an abstract interface. You can mix and match backends freely — for example, SQLite for metadata with S3 for blob storage.
Three Storage Layers
| Layer | Purpose | Default | Alternatives |
|---|---|---|---|
| Metadata Store | Relational data (packages, versions, users, tokens, publishers, audit log) | SQLite | PostgreSQL |
| Blob Store | Package tarballs, per-version screenshots, and optionally dartdoc indexed blobs | Filesystem | S3-compatible, GCS (native) |
| Search Index | Full-text package search | SQLite FTS5 | Meilisearch |
Each layer is configured independently via a single environment variable:
DB_BACKEND=sqlite # or postgresBLOB_BACKEND=filesystem # or s3 | gcsSEARCH_BACKEND=sqlite # or meilisearchOn top of these three core layers there’s one serve-strategy switch that controls where rendered dartdoc HTML comes from at request time:
DARTDOC_BACKEND=filesystem # or blob — orthogonal to BLOB_BACKENDDARTDOC_BACKEND=filesystem serves HTML trees from a local directory (DARTDOC_PATH). DARTDOC_BACKEND=blob persists a per-package indexed blob via the configured Blob Store — same one used for tarballs — and serves via byte-range reads. See Dartdoc Serving Specifications.
See Environment Variables for the full list of keys each backend accepts, and Data Directory Layout for the full /data tree showing where each layer writes on disk.
Metadata Store: SQLite vs PostgreSQL
SQLite is the default metadata store. It requires zero external dependencies and stores everything in a single file.
Pros:
- Zero setup — works out of the box
- Single-file database, easy to backup and restore
- WAL mode enables concurrent reads during writes
- Excellent performance for small to medium deployments (hundreds of packages)
- No external process to manage
Cons:
- Single-writer model (one write at a time, with WAL queuing)
- Not ideal for high-concurrency write workloads
- No built-in replication
Configuration:
DB_BACKEND=sqliteSQLITE_PATH=/data/db/club.dbSQLite pragmas set on every connection:
PRAGMA journal_mode = WAL;PRAGMA foreign_keys = ON;PRAGMA busy_timeout = 5000;PostgreSQL is recommended for larger deployments or when you need concurrent write support, replication, or advanced query features.
Pros:
- True concurrent reads and writes
- Built-in replication and high availability
- Better for high-concurrency workloads
- Rich ecosystem of monitoring and management tools
- Supports large datasets efficiently
Cons:
- Requires running and managing a PostgreSQL server
- More complex backup procedures
- Higher resource usage
Configuration:
DB_BACKEND=postgresPOSTGRES_URL=postgres://club:secret@db.example.com:5432/clubWhen to Choose PostgreSQL
Consider switching from SQLite to PostgreSQL when:
- You have more than 10 concurrent publishing users
- You need database replication or failover
- You are running multiple club server instances behind a load balancer
- Your package catalog exceeds 1,000 packages with frequent updates
Blob Store: Filesystem vs S3 vs GCS
Stores package tarballs as files on the local filesystem.
Pros:
- Zero setup
- Fast local reads (no network latency)
- Easy to backup with standard tools (
tar,rsync) - Simple to inspect and debug
Cons:
- Tied to a single server’s disk
- Disk space limited by the host
- No built-in redundancy
- Cannot be shared across multiple server instances
Configuration:
BLOB_BACKEND=filesystemBLOB_PATH=/data/blobsFile layout:
/data/blobs/├── my_package/│ ├── 1.0.0/│ │ ├── artifacts/│ │ │ └── package.tar.gz│ │ └── screenshots/│ │ ├── 0│ │ └── 1│ └── 2.0.0/│ └── artifacts/│ └── package.tar.gz├── my_package/dartdoc/latest/ # only when DARTDOC_BACKEND=blob│ ├── blob│ └── index.json└── other_package/ └── 0.1.0/ └── artifacts/ └── package.tar.gzFiles are written atomically using a temp-file-then-rename pattern to prevent partial writes. Per-version screenshots and blob-mode dartdoc live under the same package directory so the whole blob tier can be backed up as one unit.
Stores package tarballs in an S3-compatible object store. Works with AWS S3, MinIO, DigitalOcean Spaces, and other compatible services.
Pros:
- Virtually unlimited storage
- Built-in redundancy and durability (99.999999999% for AWS S3)
- Shared across multiple server instances
- Pre-signed URLs for direct client downloads (reduces server bandwidth)
Cons:
- Requires an S3-compatible service
- Network latency on reads (mitigated by pre-signed URL redirects)
- More complex to set up and debug
Configuration (AWS S3):
BLOB_BACKEND=s3S3_BUCKET=club-packagesS3_REGION=us-east-1S3_ACCESS_KEY=AKIA...S3_SECRET_KEY=wJalrXUtnFEMI...Configuration (MinIO / R2 / Spaces / B2 / GCS interop):
BLOB_BACKEND=s3S3_ENDPOINT=http://minio:9000S3_BUCKET=club-packagesS3_REGION=us-east-1S3_ACCESS_KEY=minioadminS3_SECRET_KEY=minioadminObject layout (identical to the filesystem layout):
s3://club-packages/├── my_package/1.0.0/artifacts/package.tar.gz├── my_package/1.0.0/artifacts/package.json├── my_package/1.1.0/artifacts/package.tar.gz├── my_package/2.0.0/artifacts/package.tar.gz└── other_package/0.1.0/artifacts/package.tar.gzWhen a client downloads a package, the server issues a 302 redirect to a pre-signed S3 URL. The client downloads directly from S3, reducing server bandwidth.
See Docker with S3 storage for provider-specific examples.
Uses the native Google Cloud Storage XML/JSON API with a service-account JSON or Application Default Credentials. Preferred on GCP-hosted deployments (GCE / GKE / Cloud Run).
Pros:
- Idiomatic GCP auth — no HMAC key management
- Works seamlessly with workload identity / metadata server (ADC)
- V4 signed URLs for direct client downloads when a private key is present
Cons:
- Requires Google Cloud — not portable
- Under ADC (no private key in process), the server must proxy bytes instead of redirecting
Configuration:
BLOB_BACKEND=gcsGCS_BUCKET=my-project.appspot.comGCS_CREDENTIALS_FILE=/secrets/sa.json # preferred# GCS_CREDENTIALS_JSON='{"type":"service_account",...}'# (unset both to use Application Default Credentials)Auth priority: GCS_CREDENTIALS_FILE > GCS_CREDENTIALS_JSON > ADC.
If you prefer HMAC keys over a service-account JSON, use the s3 backend with S3_ENDPOINT=https://storage.googleapis.com instead — see Docker with S3 storage.
When to Choose S3
Consider switching from filesystem to S3 when:
- You need more storage than a single disk provides
- You are running multiple club instances behind a load balancer
- You want built-in redundancy without managing RAID or replication
- You are already using AWS or a cloud provider with S3-compatible storage
Search Index: SQLite FTS5 vs Meilisearch
Uses SQLite’s built-in FTS5 full-text search engine. Runs in the same process as the server with no external dependencies.
Pros:
- Zero setup — built into SQLite
- Fast for small to medium catalogs
- No external service to manage
- Supports prefix matching and tag filters
Cons:
- Limited ranking/relevance tuning
- No typo tolerance
- Performance degrades with very large catalogs (10,000+ packages)
Configuration:
SEARCH_BACKEND=sqliteThe FTS5 index is maintained by the search-index layer, which updates the index whenever packages are created, updated, or deleted.
Uses Meilisearch as an external search engine. Provides advanced features like typo tolerance and better relevance ranking.
Pros:
- Typo-tolerant search
- Better relevance ranking
- Handles large catalogs efficiently
- Rich faceting and filtering
Cons:
- Requires running and managing a Meilisearch instance
- Additional infrastructure complexity
- Slight index latency (search results may lag a few seconds behind publishes)
Configuration:
SEARCH_BACKEND=meilisearchMEILISEARCH_URL=http://meilisearch:7700MEILISEARCH_KEY=your-api-keyWhen to Choose Meilisearch
Consider switching from SQLite FTS5 to Meilisearch when:
- You have a large package catalog (1,000+ packages)
- You want typo-tolerant search (e.g., “htttp” still finds “http”)
- You need advanced faceting or filtering beyond what FTS5 provides
Switching Backends
Switching the Metadata Store
To switch from SQLite to PostgreSQL:
- Set up a PostgreSQL database
- Update your configuration:
Terminal window DB_BACKEND=postgresPOSTGRES_URL=postgres://club:secret@db.example.com:5432/club - Restart the server — migrations run automatically on startup and create the schema
Switching the Blob Store
To switch from filesystem to S3:
- Set up an S3 bucket (or MinIO instance)
- Copy existing tarballs to the S3 bucket preserving the path structure:
Terminal window aws s3 sync /data/blobs/ s3://club-packages/ - Update your configuration:
Terminal window BLOB_BACKEND=s3S3_BUCKET=club-packagesS3_REGION=us-east-1S3_ACCESS_KEY=...S3_SECRET_KEY=... - Restart the server
Switching the Search Backend
To switch from SQLite FTS5 to Meilisearch:
- Start a Meilisearch instance
- Update your configuration:
Terminal window SEARCH_BACKEND=meilisearchMEILISEARCH_URL=http://meilisearch:7700MEILISEARCH_KEY=your-api-key - Restart the server — the search index is rebuilt automatically from the metadata store
Recommended Configurations
Small Team (up to 50 packages)
DB_BACKEND=sqliteBLOB_BACKEND=filesystemSEARCH_BACKEND=sqliteZero external dependencies. Everything runs in a single container.
Medium Team (50-500 packages)
DB_BACKEND=sqliteBLOB_BACKEND=s3SEARCH_BACKEND=sqliteOffload blob storage to S3 for durability and shared access, but keep SQLite for simplicity.
Large Organization (500+ packages)
DB_BACKEND=postgresBLOB_BACKEND=s3SEARCH_BACKEND=meilisearchFull external stack for maximum scalability, concurrency, and search quality.
Static-site archives
Package sites use local filesystem storage independently
of BLOB_BACKEND. This applies even when package blobs use S3 or GCS. The default
root is /data/sites, with one active set per package:
sites/<package>/<site>/ site.tar.gz # Archived target redirect.json # URL target, instead of site.tar.gz metadata.json # Optional display labelKeep SITES_PATH on a persistent volume and include it in backups alongside the
database and package blobs. Archive and URL-target metadata are primary data,
not regenerable caches. Use a single server process for this directory; sharing
it between multiple replicas is not supported. Leave disk headroom for uploads,
the new site set, and the previous set during replacement. Stop publishing while
backing up or restoring the directory so that a replacement cannot race the copy.
The server validates archives without executing their content. It stages a complete replacement, restores the previous set on promotion failure, and recovers interrupted promotions at startup. Files are unpacked for rendering in the browser, not served as extracted HTML from the API host.
| Environment variable | YAML key | Default |
|---|---|---|
SITES_PATH | sites_path | /data/sites |
MAX_SITES | max_sites | 20 |
MAX_SITE_ARCHIVE_BYTES | max_site_archive_bytes | 104857600 (100 MiB) |
MAX_SITES_TOTAL_BYTES | max_sites_total_bytes | 524288000 (500 MiB) |
MAX_SITE_EXPANDED_BYTES | max_site_expanded_bytes | 524288000 (500 MiB, including tar headers and padding) |
MAX_SITE_ENTRIES | max_site_entries | 10000 |
The package archive retains its separate MAX_UPLOAD_BYTES budget. Site manifests
are limited to 64 KiB. URL targets count toward the site count and manifest limit,
not archive-byte budgets. The CLI also enforces the default preparation budgets;
the browser caps an archive at 100 MiB compressed, 500 MiB expanded, and 10,000 tar
entries. Increasing server limits does not lift these client caps.
Browser previews
As of 0.8.1, archived previews work on the existing Club hostname. No additional
DNS, subdomains, exposed ports, or runner configuration are required. Club serves
only its bundled runtime at /site-runner/; uploaded sites are still delivered
as authenticated archives and extracted in the browser.
The runtime HTML carries an HTTP CSP sandbox without allow-same-origin, so
both framed and direct visits have an opaque origin. Preserve that response’s
Content-Security-Policy in your reverse proxy. Do not apply the main dashboard’s
restrictive script/connect CSP to /site-runner/: the sandboxed preview needs
Blob resources and external site assets. Runtime scripts have credential-free
CORS access; this does not change CORS or authentication on the archive API.
| Environment variable | YAML key | Default |
|---|---|---|
SITE_RUNNER_URL | site_runner_url | Unset, use the bundled same-host runner |
SITE_RUNNER_PORT | site_runner_port | 8081, used only with an explicit runner URL |
Existing separate-runner deployments remain supported. If explicitly set,
SITE_RUNNER_URL must be a separate HTTPS origin (HTTP is permitted only on
loopback), with no credentials, query, fragment, or path other than /.
The listener port must differ from LISTEN_PORT. Route that hostname only to
the runner listener; do not share Club cookies or grant credentialed API CORS.
An unset URL starts no extra listener and uses the main Club port instead.
Restart the server after changing these optional settings.
Keep /data on a persistent volume. If SITES_PATH is outside /data, mount it
too and make it writable by the container’s user. Never expose /data/sites or
serve extracted uploaded files through a static web server.
Authentication and cache behavior
The Club page requests /api/packages/<package>/sites and, for an archive target,
/api/packages/<package>/sites/<site>/archive through the normal access checks.
Private packages require authentication. Anonymous site reads are allowed only
for public packages when public packages are enabled. The runner receives site
content, not Club credentials, and provides no authenticated API proxy.
The Club page caches compressed downloads in browser Cache Storage, separated
by signed-in user (or anonymous browsing), and revalidates each opening using the
archive’s SHA-256 ETag. An unchanged archive returns 304; changed bytes return
200 and replace the local copy after verification. Access and Enable sites
checks happen before conditional responses. Preserve ETag and
Cache-Control: private, no-cache for archive responses; do not place private
archives in a shared CDN cache or strip their access checks.
The runner unpacks the archive and passes files to an inner iframe with
allow-scripts allow-forms, without allow-same-origin. The uploaded site’s
origin is opaque, isolating it from Club’s DOM and origin storage and from other
preview frames. The outer runtime and uploaded content both have opaque origins, including
when the runtime is served on the Club hostname. Extracted files stay in memory, and no Service
Worker serves them. See preview compatibility
before choosing an archived target for an application.
If you used an earlier Service-Worker-based preview prototype, clear that old runner origin’s browser site data once to remove its workers and extracted caches. The opaque runtime cannot access or delete origin storage, and does not use those old copies. Ordinary 0.8.0 opaque previews do not create extracted caches.
Cached downloads are subject to browser eviction, not guaranteed offline availability. They are also downloaded copies, not remotely revocable data. Already-open previews can continue running after an access change. The Enable sites checkbox, unchecked by default, blocks new site reads and Club redirects when unchecked without deleting sites or stopping publishes. External websites remain available at their own URLs.