Every OPENSTORAGE_* environment variable that the openstorage binary reads, with its
default, validation rules, and key interactions. Source of truth:
cmd/openstorage/config.go. Flags are in the
CLI reference.
Each setting takes its value from the first source that is set:
- Explicit flag.
- Canonical
OPENSTORAGE_*variable. It wins even when set to an empty value. - Older legacy alias (listed per variable).
--data-dirderivation (local mode).- Built-in default.
CODESTORAGE_*names are not read. 0.15.x and 0.16.x read them as a fallback; 0.17.0 removes it. Every subcommand exceptversionand the internalhookrefuses to start while aCODESTORAGE_*variable is set and itsOPENSTORAGE_*replacement is unset. The error names each pair, without values. ACODESTORAGE_*variable whose replacement is set is ignored.- An empty canonical value is never refilled from the old prefix or an older alias. It
follows the setting's normal validation and default rules: an empty storage URL derives
from
--data-diror fails startup; it never falls back to a legacy alias. - Malformed values are fail-fast startup errors naming the variable. They are never replaced by a value from the old prefix.
- Migrate before upgrading to 0.17.0: see the migration guide.
| Variable | Default | Purpose |
|---|---|---|
OPENSTORAGE_PG_URL |
— (required*) | Database URL: refs + metadata (postgres://, postgresql://, sqlite://, or the experimental s3wal://) |
OPENSTORAGE_S3_URL |
— (required*) | Pack storage URL (s3:// or file://) |
OPENSTORAGE_S3WAL_LEASE_TTL |
2m |
s3wal mode only: instance-lease TTL for the dual-serve guard (0 disables) |
OPENSTORAGE_S3_EXPECTED_BUCKET_OWNER |
unset (disabled) | Assert the bucket-owner AWS account ID on every S3 request |
OPENSTORAGE_S3_OP_LOG |
unset (disabled) | Diagnostics: append one line per S3 HTTP round trip to this file |
OPENSTORAGE_DATA_DIR |
— | Local-mode data directory; derives the two URLs above |
OPENSTORAGE_LISTEN |
127.0.0.1:0 |
Listen address |
OPENSTORAGE_METRICS_LISTEN |
unset (disabled) | Opt-in, unauthenticated Prometheus /metrics listener address |
OPENSTORAGE_ORG |
— (required) | Org name (JWT iss claim) |
OPENSTORAGE_ORG_PUBKEYS |
— (required) | PEM file with the org's public keys |
OPENSTORAGE_BASE_URL |
derived from listener | External base URL for http_url values |
OPENSTORAGE_RATE_RPS |
unset (disabled) | Per-client-IP rate limit: sustained requests/second |
OPENSTORAGE_RATE_BURST |
unset (disabled) | Rate-limit token-bucket depth |
OPENSTORAGE_TRUSTED_PROXIES |
unset | CIDR allowlist of reverse proxies for rate-limit client identity |
OPENSTORAGE_WEBHOOK_ALLOW_PRIVATE |
false |
Permit private-network webhook receivers (metadata and redirects stay blocked) |
OPENSTORAGE_SYNC_ALLOW_PRIVATE |
unset (transitional) | Unified sync egress policy: permit private-network upstreams for pull and push (metadata stays blocked) |
OPENSTORAGE_SYNC_PUSH_ALLOW_PRIVATE |
false |
Deprecated push-only spelling of the sync egress policy |
OPENSTORAGE_SECRETS_KEY |
unset (plaintext) | AES-256 key list for at-rest encryption of stored webhook secrets and upstream credentials |
OPENSTORAGE_SECRETS_MODE |
encrypt |
decrypt-only stages the key fleet-wide before encrypted writes begin |
OPENSTORAGE_PACK_QUOTA_BYTES |
10 GiB | Per-repo storage quota, packs + LFS (0 disables) |
OPENSTORAGE_LFS_STAGING_BYTES |
32 GiB | Per-replica cap on in-flight LFS upload staging (0 disables) |
OPENSTORAGE_LFS_V020_WRITE_COMPAT |
false |
Dual-publish LFS objects to the legacy v0.2.0 key during a rolling upgrade |
OPENSTORAGE_PACK_RESERVATION_TTL |
1h |
Lifetime of a crashed writer's quota reservation |
OPENSTORAGE_MAX_PUSH_BYTES |
2 × pack quota (min 2 MiB) | Decoded-byte cap on one git push (0 disables) |
OPENSTORAGE_VERIFY_SIG_BUDGET |
10000 |
Per-push verify-sig verified-commit budget (0 disables) |
OPENSTORAGE_SWEEP_INTERVAL |
1h |
Orphan-pack sweep cadence (0 disables sweeping) |
OPENSTORAGE_SWEEP_AGE_THRESHOLD |
24h |
Minimum blob age before an unregistered pack counts as an orphan |
OPENSTORAGE_SWEEP_DELETE_ENABLED |
false |
Opt-in: actually delete general orphans (list-only otherwise) |
OPENSTORAGE_SWEEP_DELETE_SUPERSEDED |
true |
Reap blobs a repack fold provably superseded |
OPENSTORAGE_SWEEP_DELETE_LFS |
false |
Opt-in: actually delete orphan LFS objects (list-only otherwise) |
OPENSTORAGE_SWEEP_DELETE_LFS_LEGACY |
false |
Post-roll opt-in: admit legacy-spelled LFS keys to the LFS reap (ADR-41 transition gate) |
OPENSTORAGE_REPO_RETENTION |
unset | Purge repositories soft-deleted longer than this (unset/0 keeps them forever) |
OPENSTORAGE_LFS_GC_INTERVAL |
unset | Unreferenced-LFS-object reclamation cadence (unset/0 disables the job) |
OPENSTORAGE_LFS_GC_GRACE |
24h |
How long an object must stay observed-unreferenced before retirement |
OPENSTORAGE_LFS_GC_OBJECT_BUDGET |
1000000 |
Git objects one reclamation run may walk |
OPENSTORAGE_LFS_GC_DELETE_ENABLED |
false |
Opt-in: actually retire unreferenced LFS registry rows (report-only otherwise) |
OPENSTORAGE_LFS_MAX_OBJECTS_PER_REPO |
100000 |
Per-repo LFS object-count cap override |
OPENSTORAGE_ABSENCE_RECONCILE_INTERVAL |
unset | Registry-vs-storage absence reconciliation cadence (unset/0 disables the job) |
OPENSTORAGE_ABSENCE_RECONCILE_BUDGET |
100000 |
Work units one reconciliation pass may charge |
OPENSTORAGE_REPACK_THRESHOLD |
10 |
Registered-pack count that triggers a background repack (0 disables) |
OPENSTORAGE_SYNC_STEAL_LEGACY |
false |
Opt-in: steal pre-lease sync claims after their adoption grace lapses |
* Required unless derived from OPENSTORAGE_DATA_DIR / --data-dir (local mode).
Database URL for the ref authority (CAS) and metadata store.
- Default: none. Startup fails unless set or derived from the data dir
(
sqlite://<dir>/codestorage.db). - Schemes:
postgres://orpostgresql://: production, any number of stateless replicas.sqlite://<absolute-path>: local mode, single node.s3wal://bucket?endpoint=…®ion=…: experimental zero-infra mode, single node. Refs and metadata live in conditional-CAS objects under the bucket's_wal/prefix (ADR-46).
- Legacy aliases:
SPIKE_PG_URL,PG_URL, scanned left to right. The first set name wins, even when empty. - PostgreSQL URIs parse like libpq (as of pgx v5.11):
+in query values is literal (encode spaces as%20), repeated parameters use the last value, and invalid percent escapes are rejected. - Postgres schema setup, including the durable River job tables, runs at startup under an advisory lock. Concurrently booting replicas are safe.
s3wal:// pairing rule and requirements:
OPENSTORAGE_S3_URLmust bes3://.file://, including the data-dir derivation, is refused at startup: the bucket must be the sole durable authority.- Point both URLs at the same bucket (the expected topology). The
packs/,lfs/, and_wal/prefixes do not overlap;_wal/is reserved. - Credentials come from the standard AWS env chain.
- A startup probe refuses stores that do not implement
If-Match/If-None-MatchPUTs faithfully. - The store must also provide strong read-after-write and list-after-write consistency. This is not probed; see the store requirements.
- Upstream sync runs use the durable local schedule in the metadata document, like SQLite local mode. Other jobs run on the in-process queue and tickers.
- All S3WAL targets remain experimental. See the storage qualification matrix for release-scoped evidence and conditional future promotion targets.
Immutable pack storage URL, opened through gocloud.dev.
- Default: none. Startup fails unless set or derived from the data dir
(
file://<dir>/blobs). - Schemes:
s3://bucket?region=...: AWS S3. For an S3-compatible store, addendpoint=http://...&s3ForcePathStyle=true. SeaweedFS is the tested self-hosted reference; others, including MinIO, are best-effort.file://<absolute-dir>: local mode.
- Legacy aliases:
SPIKE_S3_URL,BLOB_URL(same presence rule asOPENSTORAGE_PG_URL). - Credentials: the standard AWS chain (
AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY, shared config, instance roles). - With an
s3wal://database URL this must bes3://, and the same bucket is the expected target (pairing rule). - Delete-mode sweeping interacts with bucket versioning; see
OPENSTORAGE_SWEEP_DELETE_ENABLED.
TTL of the s3wal _wal/instance-lease dual-serve guard
(ADR-46
§2.11). Ignored outside s3wal mode.
- Default: unset = 2 minutes (30-second heartbeats).
- Values: a duration of at least
1s(the TTL/4 heartbeat must tolerate ordinary S3 latency jitter), or exactly0. 0disables the lease. It exists so test suites can run several processes against one bucket.servewarns loudly, and accidental dual-serve topologies go undetected.- Behavior:
- At startup,
serverefuses to run while another process holds a fresh lease on the bucket. - While running, it heartbeats every TTL/4 and fail-stops if the lease is stolen or unrenewable.
- Graceful shutdown (
SIGTERM/SIGINT) attempts a bounded, best-effort release. Failed release, hard kills, and fail-stop exits can leave a lease. - A successor must respect the holder's recorded TTL plus store-clock resolution margin, not just its own configured TTL.
- At startup,
Warning
The lease is best-effort detection with fail-stop on heartbeat loss. It is not an independent integrity guarantee or support for two servers on one bucket. Do not disable it or delete the lease by hand to recover. Follow the shutdown/restart procedure and operational signals.
Optional bucket owner
condition:
the 12-digit AWS account ID that must own the OPENSTORAGE_S3_URL bucket. AWS bucket
names are global and a deleted name can be re-registered by anyone; without this, the
blob layer trusts whatever the name resolves to.
- Default: unset. No condition is sent.
- Format: exactly 12 digits. Anything else fails boot.
- Effect: every S3 request carries
x-amz-expected-bucket-owner, so requests to a re-owned bucket fail with403 Access Denied. This covers:- the shared blob client: gocloud data plane, multipart copies, startup probe, sweeps, git-hook subprocesses, and the purge CLI;
- the
s3wal://conditional client (its own all-or-nothing middleware): every_wal/CAS request, including the boot probe and the instance lease. These ref and metadata objects include the org's public keys.
- Startup: every accepted
CreateBucketoutcome (success,BucketAlreadyOwnedByYou,BucketAlreadyExists, or a least-privilege role's toleratedAccessDenied) is followed by aHeadBucketunder the condition. A squatted name and credentials from a different account both fail boot. (CreateBucketitself cannot carry the condition.) - Presigned URLs (LFS downloads) embed it as a signed lowercase
x-amz-expected-bucket-ownerquery parameter (the spelling S3 enforces there). Clients send no extra header; altering the parameter breaks the SigV4 signature. - AWS only. SeaweedFS, MinIO, and S3 emulators ignore the condition (the parameter passes through harmlessly).
- This is defense-in-depth. The primary control is the
aws:ResourceAccountIAM condition in Deploy to production.
Diagnostics: set to a file path to append one line per S3 HTTP round trip. Covers the blob
client and the s3wal:// conditional client, in serve and in git-hook subprocesses.
<unixnano> <pid> <role> <operation> <status> <duration_us> <request-path>
- Default: unset. No middleware; zero overhead.
- The path must be absolute. Hooks run from the cache repository, so a relative path would split the log across files; it is rejected with a stderr warning.
- An unopenable path logs a stderr warning and disables the log. It never fails the process.
rolecomes from argv (openstorage:serve,openstorage:hook:pre-receive, …), so a shared log attributes each round trip to its process.- Background work adds a scope to the role, so it is not mistaken for request work:
openstorage:serve:schedulefor the sync schedule, and<role>:keyrefresh(for exampleopenstorage:serve:keyrefresh) for the S3WAL org-key refresh, which runs in every process that opens an S3WAL metadata store. - Use it to count the S3 round trips (WAN round trips on real S3) behind a request.
- Writes are
O_APPENDone-line syscalls, safe for concurrent processes. - The log grows without bound and includes object keys. Use it temporarily, like a debug log.
Local-mode data directory. The --data-dir flag wins over the variable.
- Derives every storage slot not set explicitly:
- database:
sqlite://<dir>/codestorage.db - packs:
file://<dir>/blobs - cache:
<dir>/cache
- database:
- Explicit
OPENSTORAGE_PG_URL/OPENSTORAGE_S3_URL/--cache-diralways win. - The path is made absolute at startup (git hooks re-exec the binary from another directory).
- The
codestorage.dbfilename is kept for on-disk compatibility: the binary reopens the same database with no rename or migration. See the migration guide. - Local mode is single-node by design; see ADR-13 in the local-backends notes.
Address of the HTTP listener for the REST API, git smart-HTTP, and /healthz.
It never serves metrics; see OPENSTORAGE_METRICS_LISTEN.
- Default:
127.0.0.1:0, an ephemeral port printed aslistening on .... - The
--listenflag (legacy alias--addr) wins.
Address of the opt-in Prometheus metrics listener (metrics reference, ADR-48).
- Default: unset or empty, which means no metrics listener.
- Format:
host:port, like127.0.0.1:9464.:0picks a free port, printed asmetrics listening on .... - Validation: the address is bound at startup before anything is served. An invalid,
unresolvable, or busy address, including the API listener's own address, stops
servewith exit 1. The value is not trimmed, so a whitespace-only value fails too. - Serves: only
GET /metricson its own socket. It shares nothing with the API listener.
Warning
The metrics listener has no authentication. Bind it to loopback or a private
network that only your scraper can reach. serve warns when the address binds every
interface, but the warning is not access control.
Required. Organization name: the JWT iss claim every token must carry
(auth spec).
servealways runs single-org mode: REST and git smart-HTTP share one org-key verifier.- The org row is bootstrapped from this value at startup.
Required. Path to a PEM file with one or more PKIX public key blocks that verify the org's JWTs.
- Generate a keypair with
openstorage keygen. - Allowed JWS algorithms:
ES256,RS256,EdDSA,PS256,ES384,ES512.noneandHS*are always rejected. - Validation: each block that PEM-decodes must be a
PUBLIC KEYblock of a supported family. - Caveat: parsing stops silently at the first undecodable data, so a truncated block after a valid key is dropped, not rejected. After a rotation, check that a token signed with the new key is accepted; startup success does not prove it.
Externally reachable base URL for http_url values in API responses.
- Default: derived from the bound listener. That is unroutable when binding
0.0.0.0, so set this in any real deployment or behind a reverse proxy. - Format:
http(s)://host[:port], no query or fragment. A trailing slash is stripped.
Per-client-IP token-bucket rate limit for the REST surface and (with separate bucket
state) the git smart-HTTP surface. Excess requests get 429 with Retry-After plus the
IETF RateLimit header ("default";r=0;t=<retry-after-secs>), the pair documented
upstream on every 429.
- Default: both unset; limiting is disabled.
- Values (fail-fast at startup):
RPS: a non-negative finite number.BURST: a non-negative integer.RPS > 0requiresBURST >= 1, and vice versa. Half-set pairs are startup errors.RPS=0withBURSTunset or0explicitly disables.
- Git minimum burst: the git surface raises depths below 8 to 8. One git operation is several requests; a smaller burst would make every clone/fetch/push permanently fail.
- Client identity is the TCP
RemoteAddr. Forwarded headers are not trusted unlessOPENSTORAGE_TRUSTED_PROXIESis set; otherwise, behind a proxy that pools backend connections, all clients share one bucket.
Comma-separated CIDR allowlist of reverse proxies whose forwarded client identity the rate limiters may trust. Bare IPs count as single-address entries.
- Default: unset; buckets key on
RemoteAddronly. - Keying: when the TCP peer is allowlisted, both limiters key on the rightmost
X-Forwarded-Forhop that is not itself allowlisted. Otherwise (including malformedX-Forwarded-For) they useRemoteAddr. - Validation: at startup, even while rate limiting is off. Rejects empty entries,
zone-scoped addresses, and IPv4-mapped prefixes below
/96. IPv4-mapped entries are normalized to IPv4. - List only proxies that set or append
X-Forwarded-Forthemselves. A proxy that forwards client-supplied values verbatim lets any client mint fresh buckets.
Unified egress policy for upstream sync in both directions (ADR-45).
- Default: unset (transitional; see below).
- Values:
true: both directions may target internal forges (RFC 1918, loopback, ULA, link-local). Cloud-metadata and special-purpose ranges stay denied.false: strict both ways; only public upstreams are dialable.- Unset: pushes stay strict (the deprecated
OPENSTORAGE_SYNC_PUSH_ALLOW_PRIVATEmay relax them); pulls ALLOW private upstreams, andservelogs a boot warning. - Malformed booleans are startup errors.
- Enforcement: every http(s) sync transfer (
upstream_hostis caller-linked data) dials through a vetting proxy with the webhook destination policy: private, loopback, link-local, metadata, and special-purpose targets are denied, with every DNS answer re-vetted at connect time. - Covered: pulls (
POST /repos/pull-upstream, the first sync atPOST /repos, freshness pokes) and push forwards (POST /repos/push-upstream,forward_pushes). - Denied destination: the upstream is never contacted. Pulls fail as
repository configuration error; pushes fail asinvalid sync configuration, with no retry. - Registration (
POST /reposwithupstreamorbase_repo) also runs a courtesy check against the pull policy. Dial-time enforcement stays authoritative.
Important
A future release flips the unset pull default to deny. Set this explicitly now:
true keeps internal-forge pulls working across the flip; false adopts the strict
policy early.
Migration note (0.7.x): pulls previously had no destination policy. They now always
use the vetting proxy, which dials directly, so HTTP_PROXY/HTTPS_PROXY no longer apply
to upstream fetches (same as webhooks and push forwarding). If you require proxied egress,
enforce an equivalent destination policy at that proxy; the guard cannot be chained
through an external proxy.
Deprecated push-only spelling of the sync egress policy, kept for 0.6.x deployments.
- Default:
false. - If
OPENSTORAGE_SYNC_ALLOW_PRIVATEis unset,truerelaxes the private receiver classes for push forwards only. - If the unified variable is set, this one is ignored.
- Either way, setting it logs a boot deprecation notice naming the replacement.
Controls the native webhook SSRF guard.
- Default:
false. Delivery rejects IANA special-purpose ranges, including loopback, RFC 1918, RFC 6598 shared, IPv4/IPv6 link-local, IPv6 ULA, translation/protocol anycast, documentation, benchmarking, reserved, unspecified, multicast, broadcast, and cloud-metadata targets. IPv4-mapped IPv6 is normalized first, so::ffff:10.0.0.1is private. true(Go boolean; logs a startup warning): admits loopback, RFC 1918, RFC 6598 shared, link-local, and ULA receivers. Use it only for intentionally internal webhooks. It still blocks cloud metadata, unspecified/multicast/broadcast, other IANA special-purpose ranges (documentation, benchmarking, reserved, translation, protocol-anycast), and redirects.- Malformed booleans are startup errors.
How the guard is enforced:
- Registration checks IP literals and the DNS answers visible at that moment, as fast feedback only. Acceptance is not a reachability guarantee: DNS may fail or change later.
- Every delivery attempt re-resolves at dial time. All addresses must be allowed. A resolution failure, empty answer, or mixed public/private answer fails the attempt through the normal retry/dead-letter path.
- The client connects to the vetted IP literal, so DNS cannot be rebound in between.
- No redirects:
301,302,307, and308are delivery failures; the target gets no request. - Delivery ignores
HTTP_PROXY/HTTPS_PROXY, since vetting a proxy socket would not vet the proxy's final target.
Enables envelope encryption of the metadata database's secret columns: webhook HMAC
signing secrets (webhooks.secret) and upstream-sync credential passwords
(git_credentials.password).
- Default: unset. Those columns are stored plaintext. Either way, follow the production guide: scope database access tightly and encrypt the database and its backups.
- Format: comma-separated base64-encoded 32-byte (AES-256) keys. The first key encrypts every write; the rest are decrypt-only. Generate a key with:
head -c 32 /dev/urandom | base64- Validation: a malformed list (bad base64, wrong length, duplicate or empty entries) is a startup error.
- Set the same value on every replica. Hook subprocesses inherit it from
serve. - Startup log:
secrets at-rest encryption enabled, with mode, key count, and active fingerprint. Key material never appears in logs or errors. - Stored format:
enc:v1:<keyid>:<base64 nonce+ciphertext>(AES-256-GCM).<keyid>is the first 8 hex characters of the key's SHA-256 fingerprint: public, safe to log, and usable in SQL to audit rotation. - Existing plaintext rows stay readable and are re-encrypted on their next read
(webhook deliveries, credential use, list/get API calls). A legacy plaintext value that
happens to start with
enc:reads verbatim and migrates too. - Reserved prefix: the API rejects new webhook secrets and credential passwords
starting with
enc:(HTTP 400), in plaintext mode too. - Missing key: reading a row without the key that wrote it is a request-time error naming the row and key fingerprint, never a silent passthrough.
- Recovery never needs the lost key. An undecryptable webhook is listed without its
secret and only its deliveries fail. Replacing (
PUT) or deleting a credential, and deleting and re-registering a webhook, work on undecryptable rows.
Warning
- Removing the variable while encrypted rows exist makes them unreadable. Keep the key list stable once enabled, and back the keys up outside the database.
- Binaries that predate this variable read
enc:v1:envelopes verbatim: they sign webhooks with the ciphertext or send it as an upstream password, failing silently. Upgrade every node before setting the key anywhere, and do not roll back to such a binary while encrypted rows exist.
Enable on a multi-replica fleet. Stage the rollout so no envelope exists before every process can decrypt it. A single replica (e.g. local mode) can skip to step 3.
- Binary first. Deploy an envelope-aware binary to every node (serve replicas and
hook-running git nodes) with
OPENSTORAGE_SECRETS_KEYunset. - Stage the key. Set
OPENSTORAGE_SECRETS_KEYandOPENSTORAGE_SECRETS_MODE=decrypt-onlyfleet-wide. Processes can decrypt but still write plaintext and rewrite nothing. - Encrypt. Once every replica has the key, drop
OPENSTORAGE_SECRETS_MODE(or setencrypt) fleet-wide. Encrypted writes and lazy re-encryption begin.
A drained cutover (stop all replicas, set the key, start) collapses steps 2–3.
Rotate a key. A single replica can skip step 1 (one restart swaps the list).
- Stage fleet-wide:
OPENSTORAGE_SECRETS_KEY="<old-key>,<new-key>". Writes still use the old key; rollout order does not matter. - Promote fleet-wide:
OPENSTORAGE_SECRETS_KEY="<new-key>,<old-key>". - Re-encrypt, verify, then drop the old key. Each read re-encrypts a row under the
new key. To avoid waiting, touch every row: list the org's webhooks
(
GET /api/v1/webhooks) and, per repo with a stored credential, trigger a sync (POST /api/v1/repos/pull-upstream) or re-PUTthe credential. Check progress:
SELECT count(*) FROM webhooks WHERE secret NOT LIKE 'enc:v1:<new-keyid>:%';
SELECT count(*) FROM git_credentials WHERE password NOT LIKE 'enc:v1:<new-keyid>:%';When both counts are zero, remove the old key from the list.
Staging switch for OPENSTORAGE_SECRETS_KEY.
- Default:
encrypt(normal operation). decrypt-only: loads the keys for reading but writes plaintext and does no lazy rewrites. Use it only while a rolling restart distributes the key; it stores new secrets plaintext.- Validation:
decrypt-onlywithoutOPENSTORAGE_SECRETS_KEY, or any other value, is a startup error.
Per-repository storage quota (packs + LFS), enforced at every external storage writer.
- Default: 10 GiB (
10737418240).0disables enforcement; negative or malformed values are startup errors. - Where it is enforced:
- Git pushes that would grow
packs/past the quota: refused at pre-receive, before anything is stored. - REST pack writes (commit-pack, merge, notes, fork):
403problem body. - Git LFS uploads: per-object
507batch errors before any transfer URL is issued. The verified upload re-checks the actualContent-Length, so under-declared sizes cannot bypass it.
- Git pushes that would grow
- Namespaces: a repo and its
+ephemeral/+importnamespaces (git protocol) share the base repo's quota. LFS objects always store under the base repo's prefix. - Admission uses a durable reservation ledger in the ref store, atomic across pushes and replicas: of two pushes that fit alone but not together, exactly one wins. LFS charges it too (advisory batch check, then a durable reserve at the verified upload, settled when the upload completes).
- Population bounds (compiled in, like the repack fold budgets; apply even with the
byte quota off):
- 10,000 active reservation rows per repo. Excess batches get transient
429s until rows settle or expire. Admission prunes the repo's expired rows itself, so the table stays bounded even with sweeping off. - 100,000 registered LFS objects per repo. Excess gets
507naming the limit; re-publishing registered objects is exempt.
- 10,000 active reservation rows per repo. Excess batches get transient
- Never quota-gated: background repack (so an at-quota repo can still compact) and LFS downloads. Repack keeps the registered object set; it does not reclaim unreachable objects.
Per-replica cap on the total bytes of in-flight LFS uploads. Uploads (the verified proxy
transfer) stage their full body under $TMPDIR before verification and publication. This
bound protects the replica's disk, which the per-repo quota does not.
- Default: 32 GiB (
34359738368).0disables the bound; negative or malformed values are startup errors. - A working set, not an object-size cap. One repo can hold at most a third of the
budget, so objects of size
Sneed a budget of about3 × S. The default fits one 10 GiB (default-quota-sized) object with cross-repo capacity left. - Sizing: keep it below the free space guaranteed in
$TMPDIR, minus headroom for git push quarantines and fork closures, which stage there too. - Charging: bytes are charged as they arrive, so a stalled upload holds little more
than it has transferred, not its declared
Content-Length. Each repo is held to a fair share that scales with free capacity. - Responses:
- Does not fit right now:
429withRetry-After+RateLimit. - Declared size above a third of the budget:
413. - A second concurrent upload of the same object on one replica:
429.
- Does not fit right now:
- Aborts: an upload is aborted, freeing capacity, after a full minute with no bytes or when it outlives the reservation TTL. An upload that slow (~3 MB/s for 10 GiB at defaults) could not beat its reservation's expiry anyway.
Rolling-upgrade bridge from v0.2.0. Registered LFS objects live at a canonical key (ADR-41) that old presigned upload URLs cannot address and v0.2.0-and-older replicas cannot see. Set this on upgraded replicas to also copy every verified LFS publication to the legacy key, so old replicas keep serving objects during the roll.
- Default:
false. The canonical spelling is the secure one; compat copies are exposed to still-valid legacy upload URLs, like everything v0.2.0 served. Keep it temporary. - Enforced: if the compat copy fails, the upload answers
502. The canonical object is already registered, so the retry is zero-growth and repairs the copy. Write-scoped touches of registered objects also repair a missing legacy copy. - Quota: with a quota enforced, the copy is charged
normally, so each fresh upload costs about twice its size during the roll. If the copy
would exceed the quota, the upload answers
507; the canonical object stays registered, and a retry after freeing space repairs the copy. - After the roll: drop the variable once the whole fleet is upgraded. The rowless copies are reclaimed by the orphan sweep under its LFS opt-in.
Warning
Destructive LFS sweeping must be off fleet-wide during a v0.2.0 roll: an old binary's sweeper has no legacy gate and would reap migrated objects' legacy blobs, breaking old-replica reads.
- Combining this flag with
OPENSTORAGE_SWEEP_DELETE_LFSwhile a sweep schedule is active (OPENSTORAGE_SWEEP_INTERVAL> 0) is a startup error. With the interval at0, no schedule runs, the delete flag is inert, and the fleet-wide emergency stop stays available. - That check runs only on upgraded nodes. On pre-0.15 nodes, set
CODESTORAGE_SWEEP_DELETE_LFS=falseyourself before rolling; they do not read the new prefix.
How long a crashed writer's quota reservation keeps gating admission.
- Default:
1h. - Values: a Go duration (e.g.
30m,2h) greater than 60 seconds (the 30-second usage-floor cache lifetime plus the 30-second admission handoff margin). Smaller values fail startup; they could silently reopen quota overshoot. - Sizing: comfortably exceed the longest server-side pack upload. The default covers a 10 GiB upload at ~3 MB/s.
- LFS reservations use the same TTL. They are charged at the verified upload, just before publish (the batch check holds nothing), so the TTL must cover publication and registration, not the client's transfer.
- Admission ignores expired rows immediately; the sweeper's pruning is only garbage collection.
Cap on one git push's decoded body size. A git-receive-pack body over the cap, counted
after any Content-Encoding: gzip decode, is rejected with 413 and a JSON error
envelope.
- Default: 2 ×
OPENSTORAGE_PACK_QUOTA_BYTES, at least 2 MiB. The floor keeps delete-only/zero-growth pushes (the over-quota recovery path) working under byte-scale quotas. - Values:
0disables the cap; negative or malformed values are startup errors. An explicit value is used verbatim (no floor). - Disabling the quota (
0) also disables the derived cap. To keep a cap without a quota, set this explicitly. - Scope: git pushes only, including the sync worker's loopback pushes. Fetches,
clones (
upload-pack), and LFS transfers are unaffected. - Bounded work: only bytes up to the cap reach
git receive-pack; a declared oversize is rejected unread. Git discards the truncated quarantine, and nothing is published or registered. (The pack quota bounds retained storage, after the push has streamed to disk.) - Gzip bodies are also capped on the compressed side at the cap + ~6% + 4 KiB. Git clients' single-member streams never approach it. The 413 envelope names both bounds.
- Edge case: a complete, valid push padded with junk past the cap still gets
413, but its hooks already ran under normal quota admission.
Per-push cap on verify-sig signature verifications: how many commits one push may verify
across all refs, pre-receive evaluation and update hooks combined. Each verification spawns
a git verify-commit subprocess.
- Default:
10000.0disables the budget and the walk allowance below; negative or malformed values are startup errors. - Over budget: the ref update is rejected fail-closed before the excess
verifications run, with an error naming this variable. Normal per-ref semantics apply:
a plain multi-ref push denies only over-budget refs, while
git push --atomicrejects the whole push. - Charging: once per unique commit verified. Verdicts are cached and ranges are deduplicated across refs within a push.
- Worst case: branch creation verifies everything reachable from the new tip (per
the branch-protection contract, it compares against the ref's own prior tip, never other
refs). Raise the budget before importing histories deeper than it under a
verify-sigpolicy. - Walk allowance: 100 × the budget per push. Every commit a walk examines counts (cached verdicts included), plus a small fee per ref evaluation for the verification state it loads.
Background jobs run through a queue seam: durable River jobs on Postgres, in-process in local mode. For procedures and run reports, see Operate the storage lifecycle.
On PostgreSQL, one sweep job has a 2-hour budget. With
OPENSTORAGE_REPO_RETENTION set, the sweep ends 15 min
early, so the repository purge keeps at least 15 min of its own, counted from when the
purge starts. The purge may use more when the sweep is short. A sweep that returns late,
because it finishes a delete handshake after its deadline, moves the purge later, so a job
can run for up to about 2 h 30 min. A purge that is cut short continues on a later pass.
Local mode sets no deadline.
Interval between orphan sweeps. The sweeper diffs the blob store's packs/ and lfs/
listings against their registries and reports unregistered blobs older than the age
threshold. It deletes them only when explicitly enabled. It also prunes expired quota
reservations on the same cadence.
- Default:
1h. Values: a Go duration; negative values are startup errors. 0disables sweeping. Two things still run:- a strictly restorative quarantine-heal pass, once at startup;
- an hourly janitor that reconciles legacy/canonical LFS registry-row twins that mixed-version rollouts can strand. Without it, a duplicate row would double-charge usage and the object cap indefinitely.
Minimum blob age before an unregistered pack counts as an orphan.
- Default:
24h. Values: a positive Go duration. - This is the only concurrency guard needed: in-flight pushes register their packs within a request timeout, far below any sane threshold.
Opt-in delete mode for general orphans.
- Default:
false: list-only. Candidates are logged; nothing is deleted. Values: a boolean. - Quarantine first: each deleted blob is copied to a quarantine key first. Every sweep starts with a heal pass that restores registered-but-missing packs from quarantine, so rolling back to list-only still repairs a delete-enabled crash.
- Fleet prerequisite: enable only when every node (serve replicas and
hook-running git nodes) runs a build with the post-registration publish verify
(
blob.EnsurePublished). On a mixed fleet no data is lost, but an old writer's new pack can be unavailable until the next sweep heals it. List-only is safe on any fleet. - S3 versioning: on a bucket with versioning enabled or suspended, deletes only write
delete markers; every version stays stored and billed. Startup fails when delete
mode meets such a bucket, or when versioning cannot be verified (grant
s3:GetBucketVersioningor stay list-only). Each delete-mode sweep re-checks and downgrades itself to list-only on drift.
Delete mode for the narrow superseded-by-repack class: blobs whose registry rows a
repack fold provably removed (durable pack_retained records). They are reaped after the
age threshold, and their bytes return to the repo's quota headroom.
- Default:
true(falsedisables). Values: a boolean. - Versioned S3 bucket: does not fail startup, unlike the general opt-in. Each sweep run downgrades to list-only instead; the heal pass still runs.
Delete mode for LFS orphans: lfs/ blobs with no LFS registry row, older than the age
threshold.
- Default:
false(list-only). Values: a boolean. - Separate from
OPENSTORAGE_SWEEP_DELETE_ENABLEDon purpose: every LFS object uploaded by a build older than the LFS registry (#51) has no row and would look orphaned. Write-scoped touches (upload batches naming an object, re-uploads of the same content) adopt stored objects into the registry. - Enable only after list-only sweep reports stop naming objects that should live.
- Same quarantine-first disposal and heal pass as the pack classes. On versioned S3 buckets it fails startup like the general opt-in.
- Legacy-spelled keys (
lfs/<repo_id>/<oid>, ADR-41) are excluded untilOPENSTORAGE_SWEEP_DELETE_LFS_LEGACYis also set.
Post-roll opt-in to delete legacy-spelled LFS keys (lfs/<repo_id>/<oid>, the
v0.2.0-and-older spelling,
ADR-41).
- Default:
false: legacy keys are never deleted. Values: a boolean; only meaningful withOPENSTORAGE_SWEEP_DELETE_LFS. - Why: adoption migrates objects to canonical keys but keeps the legacy source for
old replicas and still-valid presigned GETs. During a roll, those sources are rowless
orphans that
OPENSTORAGE_SWEEP_DELETE_LFSalone must not touch. - Set it only when the transition is over: every replica runs the new binary, and the last old replica exited more than 15 minutes ago (the presign expiry).
- This gate overrides every other flag: without it, no sweep mode deletes a legacy-spelled key.
- Same quarantine-first disposal as every destructive class.
Retention window for soft-deleted repositories. Once a repo has been deleted longer than
this, a purge pass on the sweep cadence permanently removes its registry and eventing
rows (refs, packs, LFS objects, credentials, upstream link) and deletes its blobs
quarantine-first. Only the repository row remains, as a purge tombstone.
openstorage purge-repo runs the same purge
synchronously for one repo.
- Default: unset (
0): soft-deleted repos are kept forever. - Values: a Go duration of at least
1h; shorter values are startup errors. The purge always waits at least an hour after deletion, so no in-flight pre-delete push can race it. - Requires sweeping:
OPENSTORAGE_SWEEP_INTERVAL> 0. The purge rides the sweep schedule. On PostgreSQL, setting retention shortens each sweep by 15 min, so that the purge keeps its own time (see Maintenance jobs). - Fleet prerequisite (also for
openstorage purge-repo): enable only when every node (serve replicas and hook-running git nodes) runs a build with the purge write fence (ref updates, pack/LFS registrations and repack folds reject purged repos). Otherwise an old node can accept a write that is stranded behind the purge while reporting success. Leaving retention unset is safe on any fleet. - Write fence: each purge fences the repo before touching anything, so a still in-flight pre-delete push fails instead of racing the reclamation.
- Blob bytes move to quarantine first and are freed when the backups age out. Sweeps
are purge-aware even with every
*_DELETE_*flag off, and independent of this variable, sopurge-repo-minted fences get the same cleanup. On versioned S3 buckets it fails startup like the delete opt-ins.
Warning
Purged repositories cannot be restored. The tombstone never releases the (org, url)
name: JWT repo claims are URL-scoped and can outlive any retention window, so reusing
the name would let a stale token authorize against the new repository.
Cadence of the unreferenced-LFS-object reclamation job
(ADR-42).
Registered LFS objects are otherwise permanent, so a git:write holder direct-PUTting
tiny unreferenced objects can exhaust the per-repo object cap for everyone (507 on
every new object).
- Default: unset (
0): the job never runs.0is also the fleet-wide emergency stop. - Values: a non-negative Go duration.
- Each run walks reachable pointer files (bounded, resumable) and stamps unreferenced
rows. With
OPENSTORAGE_LFS_GC_DELETE_ENABLED, it retires rows whose stamp outlived the grace window, refunding object count and quota bytes. See the operations guide. - Retirement removes registry rows only; the sweeper's LFS delete opt-in reaps the blobs. Arming retirement therefore requires that opt-in.
How long an LFS object must stay continuously unreferenced before reclamation may retire its registry row.
- Default:
24h. Values: a positive Go duration. - Clock: starts when a scan first finds no reachable pointer to the object. Resets on any liveness signal: a pointer becoming reachable, a re-upload, or a batch/PUT answering "already stored". Retirement re-checks the grace under the registration lock, so no signal loses the race.
- Sizing: keep it well above your longest expected upload→push gap. Pointers pushed through the normal git-lfs flow always touch the object first; raw-git pointer pushes get only the reachability scan's protection.
- With
OPENSTORAGE_LFS_GC_DELETE_ENABLED: must be at least the physical reap cadence, max(OPENSTORAGE_SWEEP_AGE_THRESHOLD,OPENSTORAGE_SWEEP_INTERVAL, the 1-hour quarantine retention floor), or startup fails. - Granularity: local mode (SQLite) stamps whole seconds, so the effective window rounds up (never down) and may run up to two seconds longer. It is never shorter than configured on either backend.
Caps the work units one reclamation run may spend, so its cost stays predictable on any repository shape.
- Default:
1000000. Values: a positive integer. - Charges:
- each git object a reachability walk lists: one unit, plus one per 4 KiB of its raw (inflated) size, so entry-stuffed trees and large blobs pay for what git expands;
- each ref a scanned namespace holds, and each registry row settlement examines: one unit;
- each attempted repository, including skips: at least one unit.
- A repo whose walk exceeds the remaining budget is retried first next run with a full budget. One that exceeds the full budget is skipped loudly until you raise it.
- Nothing is marked or retired from a partial walk.
Arms retirement. When off, the job still scans, stamps, and reports what it would retire
(rows_eligible in the run report). Roll out like the
sweep delete flags: watch report-only runs until the
eligible rows are the junk you expect, then opt in.
- Default:
false. Values: a boolean. - Requires
OPENSTORAGE_LFS_GC_INTERVAL> 0. Arming a disabled job is a startup error. - Requires, while the sweep schedule is live (security; startup fails otherwise):
OPENSTORAGE_SWEEP_DELETE_LFS, and a grace of at least the reap cadence. Retirement refunds the cap slot, but only that reap deletes the blob; decoupled, re-upload cycles would growlfs/<repo>/without bound. - Runtime check: each destructive LFS sweep pass that completes its keyspace with zero
errors records a durable fleet-wide watermark. Refunds happen only while that watermark
is fresher than the grace window. Failing or lagging sweeps and mid-flight guard
demotions freeze refunds (
reap_watermark_stale/rows_deferred_reap_stale). A fresh deployment stays report-only until its first clean destructive LFS sweep. - Legacy-spelled rows (pre-ADR-41
lfs/<repo>/<oid>keys) retire only whileOPENSTORAGE_SWEEP_DELETE_LFS_LEGACYis set and the fresh watermark records a legacy-armed sweep. Otherwise they defer (rows_deferred_legacy_unarmed). - Sweep stopped (
OPENSTORAGE_SWEEP_INTERVAL=0, the emergency stop): the process starts but demotes retirement to report-only with a warning, since nothing would reap the blobs. - Mixed-fleet prerequisite: arm only when every replica serving LFS runs a build with this feature (older replicas answer "already stored" without clearing the stamp). If report-only scans ran during the roll, also wait one full grace after the last pre-touch replica stops. See the rollout guidance.
Per-repository LFS object-count cap. Bytes do not bound row count, so the registry needs its own bound.
- Default:
100000. Values: a positive integer. - Set it identically on every replica: the serve process enforces it at registration.
- Escape hatch: raise it to unwedge a repo stuck at the cap with unreferenced junk immediately, without waiting for a reclamation grace window.
Cadence of the registry-vs-storage absence reconciliation job
(ADR-43). Each
pass pages registered lfs_objects and packs rows and point-checks that their bytes
still exist in blob storage. It is the only mechanism that detects
registered-but-missing bytes. See the
operations guide.
Absence means storage-layer loss (software deletes quarantine first). A missing registered pack is logged loudly as an integrity loss: committed refs may reference it. Absent bytes also deflate the quota admission floor, hiding equally sized rowless legacy bytes, so the pass charges each loss as a TTL'd restore cover and invalidates cached floors.
- Default: unset (
0): the job never runs. - Values: a non-negative Go duration, at most half of
OPENSTORAGE_PACK_RESERVATION_TTL; otherwise startup fails. With passes capped at a quarter of the TTL and mid-pass ticks coalesced, a long scan can never let a live cover lapse. - Non-destructive: it only inserts/refreshes reservation rows and bumps the shared
floor generation. With quota off
(
OPENSTORAGE_PACK_QUOTA_BYTES=0), it detects and reports without charging. - Admission fence (quota on): each successful pass stamps a renewal-health watermark.
When the fence arms, finite quota-gated writes (pushes, LFS uploads, adoptions) fail
closed with "quota accounting temporarily degraded, retry later" until a healthy pass
completes one full recovery rotation of both registries. Internal restore/cover
reservations are always exempt, so recovery can proceed. The fence arms when:
- a cover renewal fails while the watermark is absent or older than the cover-lapse safety bound (TTL − this interval − 2 × pass wall clock). Blips within the bound never fence.
- a renewal succeeds but the prior watermark is a full TTL old (covers may have lapsed). A fleet paused past the TTL, or quota re-enabled after an off period whose earlier passes had stamped, therefore runs one fenced recovery rotation first.
- First pass after enabling the job, or after upgrading to a version that stamps the watermark: an absent watermark alone is not lapse evidence. The pass fences only on positive evidence (a durable lapse-evidence marker, recorded whenever admission-time GC or the reservation sweep deletes an expired cover; a physically present expired cover; or a failed evidence check). Otherwise it stamps health and leaves admissions open, so an ordinary first pass does not block writes.
Important
Upgrade the whole fleet before enabling this job. Only new binaries check the fence
and record lapse evidence: pre-upgrade replicas reserve without the fence check, and
their GC deletes lapsed covers without recording evidence. When upgrading a fleet that
already runs the job, finish the roll within one
OPENSTORAGE_PACK_RESERVATION_TTL, so no cover
expires for old GC to consume. The serve boot log warns while reconciliation runs with
quota enforcement (ADR-43).
Caps the work units one reconciliation pass may charge: one per registry row examined, plus one per extra registration or twin-spelling probe. Each unit is at most one blob-store HEAD or registry point read.
- Default:
100000. Values: an integer of at least100(one row can cost up to three units, so smaller budgets cannot guarantee progress on both registries). - Resumable: a budget-limited pass parks its keyset cursor, and the next pass resumes strictly after it, so any registry size reconciles across passes.
- Budget split: up to half the budget first re-settles known losses (refreshing their covers every pass, independent of the rotation). The two registry rotations split the rest with an alternating leader. A one-hour wall-clock cap bounds the pass even when a degraded blob store slows probes.
- Cover rows: discovered losses share ONE aggregate reservation row per affected repo and registry class. Renewal re-charges each live row at one unit, entirely from the database, so the per-repo reservation-row cap cannot bind and any replica keeps every cover alive. Each full rotation re-derives the exact deficit, so shrinking lags by at most one rotation, never renewal. Details: operations guide.
- Sizing: keep the budget above twice the live cover-row population. Renewal always
covers every row, squeezing discovery when it overruns (
renewal_over_budget), which slows loss discovery and heal shedding. - Fleet-shared state: rotation cursors, traversal bookkeeping, and the
alternating-leader parity persist in
job_scan_state, so multi-replica PostgreSQL fleets share one rotation whichever replica River schedules.
Registered-pack count at which a post-write trigger schedules a background repack fold for that repository. Repack packs the union of the snapshotted packs' objects into one consolidated pack; the registered object set never shrinks.
- Default:
10. - Values:
0disables this process's triggers and follow-ups (already-queued durable jobs still run).1is rejected: folding fewer than two packs is a no-op, so it would busy-loop the trigger. Negative or malformed values are startup errors. - Per process/replica: it governs that process's post-write triggers and the
follow-up checks of jobs it consumes. Setting one push node to
2does not change other consumers of the same River queue. Mixed-threshold fleets do not promise to preserve a producer's lower threshold. - Convergence: with the same enabled threshold on every eligible consumer, pack counts
target below that threshold, not necessarily one pack. Exactly one pack requires all
those consumers at
2, quiescent writes, and successful fold progress within the fold budgets.
Opt-in to steal upstream-sync claims left by pre-lease builds. Normal claims are leased and stolen automatically once the lease expires. Pre-lease claims lack the lease and holder guards that make stealing safe; new replicas adopt them under one grace period but never steal them without this flag.
- Default:
false: adopted legacy claims are never stolen. Malformed values stay disabled. - Enable only after the whole fleet runs a lease-aware build (same fleet prerequisite
as
OPENSTORAGE_SWEEP_DELETE_ENABLED). From then on, legacy claims can only be pre-upgrade crash leftovers, and stealing them heals those wedges without manual row surgery. Safe to leave enabled permanently afterwards.
Not part of the operator surface; listed for completeness. Never set these manually.
OPENSTORAGE_GIT_REPO,OPENSTORAGE_GIT_REFS_POLICY,OPENSTORAGE_GIT_OPS,OPENSTORAGE_GIT_ATOMIC,OPENSTORAGE_GIT_PACK_QUOTA,OPENSTORAGE_GIT_PACK_RESERVATION_TTL,OPENSTORAGE_GIT_VERIFY_STATE,OPENSTORAGE_GIT_VERIFY_SIG_BUDGET,OPENSTORAGE_GIT_HOOK_ERRORS: per-request plumbing the server sets for theopenstorage hooksubprocess that git re-execs.OPENSTORAGE_ASKPASS_USERNAME,OPENSTORAGE_ASKPASS_PASSWORD: set by the upstream-sync worker for its own git credential helper.OPENSTORAGE_TEST_MODE,OPENSTORAGE_TEST_CRASH,OPENSTORAGE_TEST_CAS_DROP,OPENSTORAGE_TEST_CAS_DROP_HARD,OPENSTORAGE_TEST_HOOK_DELAY_MS,OPENSTORAGE_TEST_VERIFY_SPAWN_LOG: crash/fault injection and observability knobs for the e2e suites. The server refuses test-mode requests outside test mode.OPENSTORAGE_TEST_BACKEND,OPENSTORAGE_SDK_DIR: test-lane selection (see the testing reference) and the SDK-compatibility suite's vendor checkout location.
The Docker Compose file has its own host-port overrides (PGPORT, S3PORT,
NODEA_PORT, NODEB_PORT, CS_KEYS_DIR); see the
docker-compose.yml header.